added cargo files
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
// 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:pinepods_mobile/entities/downloadable.dart';
|
||||
|
||||
class DownloadProgress {
|
||||
final String id;
|
||||
final int percentage;
|
||||
final DownloadState status;
|
||||
|
||||
DownloadProgress(
|
||||
this.id,
|
||||
this.percentage,
|
||||
this.status,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class DownloadManager {
|
||||
Future<String?> enqueueTask(String url, String downloadPath, String fileName);
|
||||
|
||||
Stream<DownloadProgress> get downloadProgress;
|
||||
|
||||
void dispose();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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/entities/episode.dart';
|
||||
|
||||
abstract class DownloadService {
|
||||
Future<bool> downloadEpisode(Episode episode);
|
||||
|
||||
Future<Episode?> findEpisodeByTaskId(String taskId);
|
||||
|
||||
void dispose();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:pinepods_mobile/core/environment.dart';
|
||||
import 'package:pinepods_mobile/entities/downloadable.dart';
|
||||
import 'package:pinepods_mobile/services/download/download_manager.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A [DownloadManager] for handling downloading of podcasts on a mobile device.
|
||||
@pragma('vm:entry-point')
|
||||
class MobileDownloaderManager implements DownloadManager {
|
||||
static const portName = 'downloader_send_port';
|
||||
final log = Logger('MobileDownloaderManager');
|
||||
final ReceivePort _port = ReceivePort();
|
||||
final downloadController = StreamController<DownloadProgress>();
|
||||
var _lastUpdateTime = 0;
|
||||
|
||||
@override
|
||||
Stream<DownloadProgress> get downloadProgress => downloadController.stream;
|
||||
|
||||
MobileDownloaderManager() {
|
||||
_init();
|
||||
}
|
||||
|
||||
Future _init() async {
|
||||
log.fine('Initialising download manager');
|
||||
|
||||
await FlutterDownloader.initialize();
|
||||
IsolateNameServer.removePortNameMapping(portName);
|
||||
|
||||
IsolateNameServer.registerPortWithName(_port.sendPort, portName);
|
||||
|
||||
var tasks = await FlutterDownloader.loadTasks();
|
||||
|
||||
// Update the status of any tasks that may have been updated whilst
|
||||
// Pinepods was close or in the background.
|
||||
if (tasks != null && tasks.isNotEmpty) {
|
||||
for (var t in tasks) {
|
||||
_updateDownloadState(id: t.taskId, progress: t.progress, status: t.status);
|
||||
|
||||
/// If we are not queued or running we can safely clean up this event
|
||||
if (t.status != DownloadTaskStatus.enqueued && t.status != DownloadTaskStatus.running) {
|
||||
FlutterDownloader.remove(taskId: t.taskId, shouldDeleteContent: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_port.listen((dynamic data) {
|
||||
final id = (data as List<dynamic>)[0] as String;
|
||||
final status = DownloadTaskStatus.fromInt(data[1] as int);
|
||||
final progress = data[2] as int;
|
||||
|
||||
_updateDownloadState(id: id, progress: progress, status: status);
|
||||
});
|
||||
|
||||
FlutterDownloader.registerCallback(downloadCallback);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> enqueueTask(String url, String downloadPath, String fileName) async {
|
||||
return await FlutterDownloader.enqueue(
|
||||
url: url,
|
||||
savedDir: downloadPath,
|
||||
fileName: fileName,
|
||||
showNotification: true,
|
||||
openFileFromNotification: false,
|
||||
headers: {
|
||||
'User-Agent': Environment.userAgent(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
IsolateNameServer.removePortNameMapping(portName);
|
||||
downloadController.close();
|
||||
}
|
||||
|
||||
void _updateDownloadState({required String id, required int progress, required DownloadTaskStatus status}) {
|
||||
var state = DownloadState.none;
|
||||
var updateTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
if (status == DownloadTaskStatus.enqueued) {
|
||||
state = DownloadState.queued;
|
||||
} else if (status == DownloadTaskStatus.canceled) {
|
||||
state = DownloadState.cancelled;
|
||||
} else if (status == DownloadTaskStatus.complete) {
|
||||
state = DownloadState.downloaded;
|
||||
} else if (status == DownloadTaskStatus.running) {
|
||||
state = DownloadState.downloading;
|
||||
} else if (status == DownloadTaskStatus.failed) {
|
||||
state = DownloadState.failed;
|
||||
} else if (status == DownloadTaskStatus.paused) {
|
||||
state = DownloadState.paused;
|
||||
}
|
||||
|
||||
/// If we are running, we want to limit notifications to 1 per second. Otherwise,
|
||||
/// small downloads can cause a flood of events. Any other status we always want
|
||||
/// to push through.
|
||||
if (status != DownloadTaskStatus.running ||
|
||||
progress == 0 ||
|
||||
progress == 100 ||
|
||||
updateTime > _lastUpdateTime + 1000) {
|
||||
downloadController.add(DownloadProgress(id, progress, state));
|
||||
_lastUpdateTime = updateTime;
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
static void downloadCallback(String id, int status, int progress) {
|
||||
IsolateNameServer.lookupPortByName('downloader_send_port')?.send([id, status, progress]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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 'dart:io';
|
||||
|
||||
import 'package:pinepods_mobile/core/utils.dart';
|
||||
import 'package:pinepods_mobile/entities/downloadable.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/entities/transcript.dart';
|
||||
import 'package:pinepods_mobile/repository/repository.dart';
|
||||
import 'package:pinepods_mobile/services/download/download_manager.dart';
|
||||
import 'package:pinepods_mobile/services/download/download_service.dart';
|
||||
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mp3_info/mp3_info.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// An implementation of a [DownloadService] that handles downloading
|
||||
/// of episodes on mobile.
|
||||
class MobileDownloadService extends DownloadService {
|
||||
static BehaviorSubject<DownloadProgress> downloadProgress = BehaviorSubject<DownloadProgress>();
|
||||
|
||||
final log = Logger('MobileDownloadService');
|
||||
final Repository repository;
|
||||
final DownloadManager downloadManager;
|
||||
final PodcastService podcastService;
|
||||
|
||||
MobileDownloadService({required this.repository, required this.downloadManager, required this.podcastService}) {
|
||||
downloadManager.downloadProgress.pipe(downloadProgress);
|
||||
downloadProgress.listen((progress) {
|
||||
_updateDownloadProgress(progress);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
downloadManager.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> downloadEpisode(Episode episode) async {
|
||||
try {
|
||||
final season = episode.season > 0 ? episode.season.toString() : '';
|
||||
final epno = episode.episode > 0 ? episode.episode.toString() : '';
|
||||
var dirty = false;
|
||||
|
||||
if (await hasStoragePermission()) {
|
||||
// If this episode contains chapter, fetch them first.
|
||||
if (episode.hasChapters && episode.chaptersUrl != null) {
|
||||
var chapters = await podcastService.loadChaptersByUrl(url: episode.chaptersUrl!);
|
||||
|
||||
episode.chapters = chapters;
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Next, if the episode supports transcripts download that next
|
||||
if (episode.hasTranscripts) {
|
||||
var sub = episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.json);
|
||||
|
||||
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.subrip);
|
||||
|
||||
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.html);
|
||||
|
||||
if (sub != null) {
|
||||
var transcript = await podcastService.loadTranscriptByUrl(transcriptUrl: sub);
|
||||
|
||||
transcript = await podcastService.saveTranscript(transcript);
|
||||
|
||||
episode.transcript = transcript;
|
||||
episode.transcriptId = transcript.id;
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await podcastService.saveEpisode(episode);
|
||||
}
|
||||
|
||||
final episodePath = await resolveDirectory(episode: episode);
|
||||
final downloadPath = await resolveDirectory(episode: episode, full: true);
|
||||
var uri = Uri.parse(episode.contentUrl!);
|
||||
|
||||
// Ensure the download directory exists
|
||||
await createDownloadDirectory(episode);
|
||||
|
||||
// Filename should be last segment of URI.
|
||||
var filename = safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.mp3')));
|
||||
|
||||
filename ??= safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.m4a')));
|
||||
|
||||
if (filename == null) {
|
||||
//TODO: Handle unsupported format.
|
||||
} else {
|
||||
// The last segment could also be a full URL. Take a second pass.
|
||||
if (filename.contains('/')) {
|
||||
try {
|
||||
uri = Uri.parse(filename);
|
||||
filename = uri.pathSegments.last;
|
||||
} on FormatException {
|
||||
// It wasn't a URL...
|
||||
}
|
||||
}
|
||||
|
||||
// Some podcasts use the same file name for each episode. If we have a
|
||||
// season and/or episode number provided by iTunes we can use that. We
|
||||
// will also append the filename with the publication date if available.
|
||||
var pubDate = '';
|
||||
|
||||
if (episode.publicationDate != null) {
|
||||
pubDate = '${episode.publicationDate!.millisecondsSinceEpoch ~/ 1000}-';
|
||||
}
|
||||
|
||||
filename = '$season$epno$pubDate$filename';
|
||||
|
||||
log.fine('Download episode (${episode.title}) $filename to $downloadPath/$filename');
|
||||
|
||||
/// If we get a redirect to an http endpoint the download will fail. Let's fully resolve
|
||||
/// the URL before calling download and ensure it is https.
|
||||
var url = await resolveUrl(episode.contentUrl!, forceHttps: true);
|
||||
|
||||
final taskId = await downloadManager.enqueueTask(url, downloadPath, filename);
|
||||
|
||||
// Update the episode with download data
|
||||
episode.filepath = episodePath;
|
||||
episode.filename = filename;
|
||||
episode.downloadTaskId = taskId;
|
||||
episode.downloadState = DownloadState.downloading;
|
||||
episode.downloadPercentage = 0;
|
||||
|
||||
await repository.saveEpisode(episode);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e, stack) {
|
||||
log.warning('Episode download failed (${episode.title})', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Episode?> findEpisodeByTaskId(String taskId) {
|
||||
return repository.findEpisodeByTaskId(taskId);
|
||||
}
|
||||
|
||||
Future<void> _updateDownloadProgress(DownloadProgress progress) async {
|
||||
var episode = await repository.findEpisodeByTaskId(progress.id);
|
||||
|
||||
if (episode != null) {
|
||||
// We might be called during the cleanup routine during startup.
|
||||
// Do not bother updating if nothing has changed.
|
||||
if (episode.downloadPercentage != progress.percentage || episode.downloadState != progress.status) {
|
||||
episode.downloadPercentage = progress.percentage;
|
||||
episode.downloadState = progress.status;
|
||||
|
||||
if (progress.percentage == 100) {
|
||||
if (await hasStoragePermission()) {
|
||||
final filename = await resolvePath(episode);
|
||||
|
||||
// If we do not have a duration for this file - let's calculate it
|
||||
if (episode.duration == 0) {
|
||||
var mp3Info = MP3Processor.fromFile(File(filename));
|
||||
|
||||
episode.duration = mp3Info.duration.inSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await repository.saveEpisode(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user