added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,52 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';
typedef DatabaseUpgrade = Future<void> Function(Database, int, int);
/// Provides a database instance to other services and handles the opening
/// of the Sembast DB.
class DatabaseService {
Completer<Database>? _databaseCompleter;
String databaseName;
int? version = 1;
DatabaseUpgrade? upgraderCallback;
DatabaseService(
this.databaseName, {
this.version,
this.upgraderCallback,
});
Future<Database> get database async {
if (_databaseCompleter == null) {
_databaseCompleter = Completer();
await _openDatabase();
}
return _databaseCompleter!.future;
}
Future _openDatabase() async {
final appDocumentDir = await getApplicationDocumentsDirectory();
final dbPath = join(appDocumentDir.path, databaseName);
final database = await databaseFactoryIo.openDatabase(
dbPath,
version: version,
onVersionChanged: (db, oldVersion, newVersion) async {
if (upgraderCallback != null) {
await upgraderCallback!(db, oldVersion, newVersion);
}
},
);
_databaseCompleter!.complete(database);
}
}

View File

@@ -0,0 +1,681 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/core/extensions.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/queue.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/repository/sembast/sembast_database_service.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:sembast/sembast.dart';
/// An implementation of [Repository] that is backed by
/// [Sembast](https://github.com/tekartik/sembast.dart/tree/master/sembast)
class SembastRepository extends Repository {
final log = Logger('SembastRepository');
final _podcastSubject = BehaviorSubject<Podcast>();
final _episodeSubject = BehaviorSubject<EpisodeState>();
final _podcastStore = intMapStoreFactory.store('podcast');
final _episodeStore = intMapStoreFactory.store('episode');
final _queueStore = intMapStoreFactory.store('queue');
final _transcriptStore = intMapStoreFactory.store('transcript');
final _queueGuids = <String>[];
late DatabaseService _databaseService;
Future<Database> get _db async => _databaseService.database;
SembastRepository({
bool cleanup = true,
String databaseName = 'pinepods.db',
}) {
_databaseService = DatabaseService(databaseName, version: 2, upgraderCallback: dbUpgrader);
if (cleanup) {
_cleanupEpisodes().then((value) {
log.fine('Orphan episodes cleanup complete');
});
}
}
/// Saves the [Podcast] instance and associated [Episode]s. Podcasts are
/// only stored when we subscribe to them, so at the point we store a
/// new podcast we store the current [DateTime] to mark the
/// subscription date.
@override
Future<Podcast> savePodcast(Podcast podcast, {bool withEpisodes = true}) async {
log.fine('Saving podcast (${podcast.id ?? -1}) ${podcast.url}');
final finder = podcast.id == null
? Finder(filter: Filter.equals('guid', podcast.guid))
: Finder(filter: Filter.byKey(podcast.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
podcast.lastUpdated = DateTime.now();
if (snapshot == null) {
podcast.subscribedDate = DateTime.now();
podcast.id = await _podcastStore.add(await _db, podcast.toMap());
} else {
await _podcastStore.update(await _db, podcast.toMap(), finder: finder);
}
if (withEpisodes) {
await _saveEpisodes(podcast.episodes);
}
_podcastSubject.add(podcast);
return podcast;
}
@override
Future<List<Podcast>> subscriptions() async {
// Custom sort order to ignore title case.
final titleSortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title1.toLowerCase().compareTo(title2.toLowerCase());
});
final finder = Finder(sortOrders: [
titleSortOrder,
]);
final List<RecordSnapshot<int, Map<String, Object?>>> subscriptionSnapshot = await _podcastStore.find(
await _db,
finder: finder,
);
final subs = subscriptionSnapshot.map((snapshot) {
final subscription = Podcast.fromMap(snapshot.key, snapshot.value);
return subscription;
}).toList();
return subs;
}
@override
Future<void> deletePodcast(Podcast podcast) async {
final db = await _db;
await db.transaction((txn) async {
final podcastFinder = Finder(filter: Filter.byKey(podcast.id));
final episodeFinder = Finder(filter: Filter.equals('pguid', podcast.guid));
await _podcastStore.delete(
txn,
finder: podcastFinder,
);
await _episodeStore.delete(
txn,
finder: episodeFinder,
);
});
}
@override
Future<Podcast?> findPodcastById(num id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
var p = Podcast.fromMap(snapshot.key, snapshot.value);
// Now attach all episodes for this podcast
p.episodes = await findEpisodesByPodcastGuid(
p.guid,
filter: p.filter,
sort: p.sort,
);
return p;
}
return null;
}
@override
Future<Podcast?> findPodcastByGuid(String guid) async {
final finder = Finder(filter: Filter.equals('guid', guid));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _podcastStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
var p = Podcast.fromMap(snapshot.key, snapshot.value);
// Now attach all episodes for this podcast
p.episodes = await findEpisodesByPodcastGuid(
p.guid,
filter: p.filter,
sort: p.sort,
);
return p;
}
return null;
}
@override
Future<List<Episode>> findAllEpisodes() async {
final finder = Finder(
sortOrders: [SortOrder('publicationDate', false)],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<Episode?> findEpisodeById(int? id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>> snapshot =
(await _episodeStore.findFirst(await _db, finder: finder))!;
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}
@override
Future<Episode?> findEpisodeByGuid(String guid) async {
final finder = Finder(filter: Filter.equals('guid', guid));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
return null;
}
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}
// TODO: Remove nullable on pguid as this does not make sense.
@override
Future<List<Episode>> findEpisodesByPodcastGuid(
String? pguid, {
PodcastEpisodeFilter filter = PodcastEpisodeFilter.none,
PodcastEpisodeSort sort = PodcastEpisodeSort.none,
}) async {
var episodeFilter = Filter.equals('pguid', pguid);
var sortOrder = SortOrder('publicationDate', false);
// If we have an additional episode filter and/or sort, apply it.
episodeFilter = _applyEpisodeFilter(filter, episodeFilter, pguid);
sortOrder = _applyEpisodeSort(sort, sortOrder);
final finder = Finder(
filter: episodeFilter,
sortOrders: [sortOrder],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) async {
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
}).toList();
final episodeList = Future.wait(results);
return episodeList;
}
@override
Future<List<Episode>> findDownloadsByPodcastGuid(String pguid) async {
final finder = Finder(
filter: Filter.and([
Filter.equals('pguid', pguid),
Filter.equals('downloadPercentage', '100'),
]),
sortOrders: [SortOrder('publicationDate', false)],
);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<List<Episode>> findDownloads() async {
final finder =
Finder(filter: Filter.equals('downloadPercentage', '100'), sortOrders: [SortOrder('publicationDate', false)]);
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: finder);
final results = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
return results;
}
@override
Future<void> deleteEpisode(Episode episode) async {
final finder = Finder(filter: Filter.byKey(episode.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
// Oops!
} else {
await _episodeStore.delete(await _db, finder: finder);
_episodeSubject.add(EpisodeDeleteState(episode));
}
}
@override
Future<void> deleteEpisodes(List<Episode> episodes) async {
var d = await _db;
if (episodes.isNotEmpty) {
for (var chunk in episodes.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var episode in chunk) {
final finder = Finder(filter: Filter.byKey(episode.id));
futures.add(_episodeStore.delete(txn, finder: finder));
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
@override
Future<Episode> saveEpisode(Episode episode, [bool updateIfSame = false]) async {
var e = await _saveEpisode(episode, updateIfSame);
_episodeSubject.add(EpisodeUpdateState(e));
return e;
}
@override
Future<List<Episode>> saveEpisodes(List<Episode> episodes, [bool updateIfSame = false]) async {
final updatedEpisodes = <Episode>[];
for (var es in episodes) {
var e = await _saveEpisode(es, updateIfSame);
updatedEpisodes.add(e);
_episodeSubject.add(EpisodeUpdateState(e));
}
return updatedEpisodes;
}
@override
Future<List<Episode>> loadQueue() async {
var episodes = <Episode>[];
final RecordSnapshot<int, Map<String, Object?>>? snapshot = await _queueStore.record(1).getSnapshot(await _db);
if (snapshot != null) {
var queue = Queue.fromMap(snapshot.key, snapshot.value);
var episodeFinder = Finder(filter: Filter.inList('guid', queue.guids));
final List<RecordSnapshot<int, Map<String, Object?>>> recordSnapshots =
await _episodeStore.find(await _db, finder: episodeFinder);
episodes = recordSnapshots.map((snapshot) {
final episode = Episode.fromMap(snapshot.key, snapshot.value);
return episode;
}).toList();
}
return episodes;
}
@override
Future<void> saveQueue(List<Episode> episodes) async {
/// Check to see if we have any ad-hoc episodes and save them first
for (var e in episodes) {
if (e.pguid == null || e.pguid!.isEmpty) {
_saveEpisode(e, false);
}
}
var guids = episodes.map((e) => e.guid).toList();
/// Only bother saving if the queue has changed
if (!listEquals(guids, _queueGuids)) {
final queue = Queue(guids: guids);
await _queueStore.record(1).put(await _db, queue.toMap());
_queueGuids.clear();
_queueGuids.addAll(guids);
}
}
@override
Future<Transcript?> findTranscriptById(int? id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
return snapshot == null ? null : Transcript.fromMap(snapshot.key, snapshot.value);
}
@override
Future<void> deleteTranscriptById(int id) async {
final finder = Finder(filter: Filter.byKey(id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
// Oops!
} else {
await _transcriptStore.delete(await _db, finder: finder);
}
}
@override
Future<void> deleteTranscriptsById(List<int> id) async {
var d = await _db;
if (id.isNotEmpty) {
for (var chunk in id.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var id in chunk) {
final finder = Finder(filter: Filter.byKey(id));
futures.add(_transcriptStore.delete(txn, finder: finder));
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
@override
Future<Transcript> saveTranscript(Transcript transcript) async {
final finder = Finder(filter: Filter.byKey(transcript.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _transcriptStore.findFirst(await _db, finder: finder);
transcript.lastUpdated = DateTime.now();
if (snapshot == null) {
transcript.id = await _transcriptStore.add(await _db, transcript.toMap());
} else {
await _transcriptStore.update(await _db, transcript.toMap(), finder: finder);
}
return transcript;
}
Future<void> _cleanupEpisodes() async {
final threshold = DateTime.now().subtract(const Duration(days: 60)).millisecondsSinceEpoch;
/// Find all streamed episodes over the threshold.
final filter = Filter.and([
Filter.equals('downloadState', 0),
Filter.lessThan('lastUpdated', threshold),
]);
final orphaned = <Episode>[];
final pguids = <String?>[];
final List<RecordSnapshot<int, Map<String, Object?>>> episodes =
await _episodeStore.find(await _db, finder: Finder(filter: filter));
// First, find all podcasts
for (var podcast in await _podcastStore.find(await _db)) {
pguids.add(podcast.value['guid'] as String?);
}
for (var episode in episodes) {
final pguid = episode.value['pguid'] as String?;
final podcast = pguids.contains(pguid);
if (!podcast) {
orphaned.add(Episode.fromMap(episode.key, episode.value));
}
}
await deleteEpisodes(orphaned);
}
SortOrder<Object?> _applyEpisodeSort(PodcastEpisodeSort sort, SortOrder<Object?> sortOrder) {
switch (sort) {
case PodcastEpisodeSort.none:
case PodcastEpisodeSort.latestFirst:
sortOrder = SortOrder('publicationDate', false);
break;
case PodcastEpisodeSort.earliestFirst:
sortOrder = SortOrder('publicationDate', true);
break;
case PodcastEpisodeSort.alphabeticalDescending:
sortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title2.toLowerCase().compareTo(title1.toLowerCase());
});
break;
case PodcastEpisodeSort.alphabeticalAscending:
sortOrder = SortOrder<String>.custom('title', (title1, title2) {
return title1.toLowerCase().compareTo(title2.toLowerCase());
});
break;
}
return sortOrder;
}
Filter _applyEpisodeFilter(PodcastEpisodeFilter filter, Filter episodeFilter, String? pguid) {
// If we have an additional episode filter, apply it.
switch (filter) {
case PodcastEpisodeFilter.none:
episodeFilter = Filter.equals('pguid', pguid);
break;
case PodcastEpisodeFilter.started:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.notEquals('position', '0')]);
break;
case PodcastEpisodeFilter.played:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'true')]);
break;
case PodcastEpisodeFilter.notPlayed:
episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'false')]);
break;
}
return episodeFilter;
}
/// Saves a list of episodes to the repository. To improve performance we
/// split the episodes into chunks of 100 and save any that have been updated
/// in that chunk in a single transaction.
Future<void> _saveEpisodes(List<Episode?>? episodes) async {
var d = await _db;
var dateStamp = DateTime.now();
if (episodes != null && episodes.isNotEmpty) {
for (var chunk in episodes.chunk(100)) {
await d.transaction((txn) async {
var futures = <Future<int>>[];
for (var episode in chunk) {
episode!.lastUpdated = dateStamp;
if (episode.id == null) {
futures.add(_episodeStore.add(txn, episode.toMap()).then((id) => episode.id = id));
} else {
final finder = Finder(filter: Filter.byKey(episode.id));
var existingEpisode = await findEpisodeById(episode.id);
if (existingEpisode == null || existingEpisode != episode) {
futures.add(_episodeStore.update(txn, episode.toMap(), finder: finder));
}
}
}
if (futures.isNotEmpty) {
await Future.wait(futures);
}
});
}
}
}
Future<Episode> _saveEpisode(Episode episode, bool updateIfSame) async {
final finder = Finder(filter: Filter.byKey(episode.id));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot == null) {
episode.lastUpdated = DateTime.now();
episode.id = await _episodeStore.add(await _db, episode.toMap());
} else {
var e = Episode.fromMap(episode.id, snapshot.value);
episode.lastUpdated = DateTime.now();
if (updateIfSame || episode != e) {
await _episodeStore.update(await _db, episode.toMap(), finder: finder);
}
}
return episode;
}
@override
Future<Episode?> findEpisodeByTaskId(String taskId) async {
final finder = Finder(filter: Filter.equals('downloadTaskId', taskId));
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await _episodeStore.findFirst(await _db, finder: finder);
if (snapshot != null) {
return await _loadEpisodeSnapshot(snapshot.key, snapshot.value);
} else {
return null;
}
}
Future<Episode> _loadEpisodeSnapshot(int key, Map<String, Object?> snapshot) async {
var episode = Episode.fromMap(key, snapshot);
if (episode.transcriptId! > 0) {
episode.transcript = await findTranscriptById(episode.transcriptId);
}
return episode;
}
@override
Future<void> close() async {
final d = await _db;
await d.close();
}
Future<void> dbUpgrader(Database db, int oldVersion, int newVersion) async {
if (oldVersion == 1) {
await _upgradeV2(db);
}
}
/// In v1 we allowed http requests, where as now we force to https. As we currently use the
/// URL as the GUID we need to upgrade any followed podcasts that have a http base to https.
/// We use the passed [Database] rather than _db to prevent deadlocking, hence the direct
/// update to data within this routine rather than using the existing find/update methods.
Future<void> _upgradeV2(Database db) async {
List<RecordSnapshot<int, Map<String, Object?>>> data = await _podcastStore.find(db);
final podcasts = data.map((e) => Podcast.fromMap(e.key, e.value)).toList();
log.info('Upgrading Sembast store to V2');
for (var podcast in podcasts) {
if (podcast.guid!.startsWith('http:')) {
final idFinder = Finder(filter: Filter.byKey(podcast.id));
final guid = podcast.guid!.replaceFirst('http:', 'https:');
final episodeFinder = Finder(
filter: Filter.equals('pguid', podcast.guid),
);
log.fine('Upgrading GUID ${podcast.guid} - to $guid');
var upgradedPodcast = Podcast(
id: podcast.id,
guid: guid,
url: podcast.url,
link: podcast.link,
title: podcast.title,
description: podcast.description,
imageUrl: podcast.imageUrl,
thumbImageUrl: podcast.thumbImageUrl,
copyright: podcast.copyright,
funding: podcast.funding,
persons: podcast.persons,
lastUpdated: DateTime.now(),
);
final List<RecordSnapshot<int, Map<String, Object?>>> episodeData =
await _episodeStore.find(db, finder: episodeFinder);
final episodes = episodeData.map((e) => Episode.fromMap(e.key, e.value)).toList();
// Now upgrade episodes
for (var e in episodes) {
e.pguid = guid;
log.fine('Updating episode guid for ${e.title} from ${e.pguid} to $guid');
final epf = Finder(filter: Filter.byKey(e.id));
await _episodeStore.update(db, e.toMap(), finder: epf);
}
upgradedPodcast.episodes = episodes;
await _podcastStore.update(db, upgradedPodcast.toMap(), finder: idFinder);
}
}
}
@override
Stream<EpisodeState> get episodeListener => _episodeSubject.stream;
@override
Stream<Podcast> get podcastListener => _podcastSubject.stream;
}