mirror of https://github.com/KDE/kasts.git
497 lines
17 KiB
C++
497 lines
17 KiB
C++
/**
|
|
* SPDX-FileCopyrightText: 2020 Tobias Fella <tobias.fella@kde.org>
|
|
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
|
|
*
|
|
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
|
*/
|
|
|
|
#include "enclosure.h"
|
|
#include "enclosurelogging.h"
|
|
|
|
#include <KLocalizedString>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QImage>
|
|
#include <QMimeDatabase>
|
|
#include <QNetworkReply>
|
|
#include <QSqlQuery>
|
|
|
|
#include <attachedpictureframe.h>
|
|
#include <id3v2frame.h>
|
|
#include <id3v2tag.h>
|
|
#include <mpegfile.h>
|
|
|
|
#include "audiomanager.h"
|
|
#include "database.h"
|
|
#include "datamanager.h"
|
|
#include "entry.h"
|
|
#include "error.h"
|
|
#include "fetcher.h"
|
|
#include "models/downloadmodel.h"
|
|
#include "models/errorlogmodel.h"
|
|
#include "settingsmanager.h"
|
|
#include "sync/sync.h"
|
|
#include "utils/enclosuredownloadjob.h"
|
|
#include "utils/networkconnectionmanager.h"
|
|
#include "utils/storagemanager.h"
|
|
|
|
Enclosure::Enclosure(Entry *entry)
|
|
: QObject(entry)
|
|
, m_entry(entry)
|
|
{
|
|
connect(this, &Enclosure::playPositionChanged, this, &Enclosure::leftDurationChanged);
|
|
connect(this, &Enclosure::statusChanged, &DownloadModel::instance(), &DownloadModel::monitorDownloadStatus);
|
|
connect(this, &Enclosure::downloadError, &ErrorLogModel::instance(), &ErrorLogModel::monitorErrorMessages);
|
|
connect(&Fetcher::instance(), &Fetcher::entryUpdated, this, [this](const QString &url, const QString &id) {
|
|
if ((m_entry->feed()->url() == url) && (m_entry->id() == id)) {
|
|
updateFromDb();
|
|
}
|
|
});
|
|
|
|
// we use the relayed signal from AudioManager::playbackRateChanged by
|
|
// DataManager; this is required to avoid a dependency loop on startup
|
|
connect(&DataManager::instance(), &DataManager::playbackRateChanged, this, &Enclosure::leftDurationChanged);
|
|
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("SELECT * FROM Enclosures WHERE id=:id"));
|
|
query.bindValue(QStringLiteral(":id"), entry->id());
|
|
Database::instance().execute(query);
|
|
|
|
if (!query.next()) {
|
|
return;
|
|
}
|
|
|
|
m_duration = query.value(QStringLiteral("duration")).toInt();
|
|
m_size = query.value(QStringLiteral("size")).toInt();
|
|
m_title = query.value(QStringLiteral("title")).toString();
|
|
m_type = query.value(QStringLiteral("type")).toString();
|
|
m_url = query.value(QStringLiteral("url")).toString();
|
|
m_playposition = query.value(QStringLiteral("playposition")).toLongLong();
|
|
m_status = dbToStatus(query.value(QStringLiteral("downloaded")).toInt());
|
|
m_playposition_dbsave = m_playposition;
|
|
|
|
checkSizeOnDisk();
|
|
}
|
|
|
|
void Enclosure::updateFromDb()
|
|
{
|
|
// This method is used to update the most relevant fields from the RSS feed,
|
|
// most notably the download URL. It's deliberatly only updating the
|
|
// duration and size if the URL has changed, since these values are
|
|
// notably untrustworthy. We generally get them from the files themselves
|
|
// at the time they are downloaded.
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("SELECT * FROM Enclosures WHERE id=:id"));
|
|
query.bindValue(QStringLiteral(":id"), m_entry->id());
|
|
Database::instance().execute(query);
|
|
|
|
if (!query.next()) {
|
|
return;
|
|
}
|
|
|
|
if (m_url != query.value(QStringLiteral("url")).toString() && m_status != Downloaded) {
|
|
// this means that the audio file has changed, or at least its location
|
|
// let's only do something if the file isn't downloaded.
|
|
// try to delete the file first (it actually shouldn't exist)
|
|
deleteFile();
|
|
|
|
m_url = query.value(QStringLiteral("url")).toString();
|
|
Q_EMIT urlChanged(m_url);
|
|
Q_EMIT pathChanged(path());
|
|
|
|
if (m_duration != query.value(QStringLiteral("duration")).toInt()) {
|
|
m_duration = query.value(QStringLiteral("duration")).toInt();
|
|
Q_EMIT durationChanged();
|
|
}
|
|
if (m_size != query.value(QStringLiteral("size")).toInt()) {
|
|
m_size = query.value(QStringLiteral("size")).toInt();
|
|
Q_EMIT sizeChanged();
|
|
}
|
|
if (m_title != query.value(QStringLiteral("title")).toString()) {
|
|
m_title = query.value(QStringLiteral("title")).toString();
|
|
Q_EMIT titleChanged(m_title);
|
|
}
|
|
if (m_type != query.value(QStringLiteral("type")).toString()) {
|
|
m_type = query.value(QStringLiteral("type")).toString();
|
|
Q_EMIT typeChanged(m_type);
|
|
}
|
|
}
|
|
}
|
|
|
|
int Enclosure::statusToDb(Enclosure::Status status)
|
|
{
|
|
return static_cast<int>(status);
|
|
}
|
|
|
|
Enclosure::Status Enclosure::dbToStatus(int value)
|
|
{
|
|
return Enclosure::Status(value);
|
|
}
|
|
|
|
void Enclosure::download()
|
|
{
|
|
if (m_status == Downloaded) {
|
|
return;
|
|
}
|
|
|
|
if (!NetworkConnectionManager::instance().episodeDownloadsAllowed()) {
|
|
if (NetworkConnectionManager::instance().networkReachable()) {
|
|
Q_EMIT downloadError(Error::Type::MeteredNotAllowed,
|
|
m_entry->feed()->url(),
|
|
m_entry->id(),
|
|
0,
|
|
i18n("Podcast downloads not allowed on metered connection"),
|
|
QString());
|
|
return;
|
|
} else {
|
|
Q_EMIT downloadError(Error::Type::NoNetwork, m_entry->feed()->url(), m_entry->id(), 0, i18n("No network connection"), QString());
|
|
return;
|
|
}
|
|
}
|
|
|
|
checkSizeOnDisk();
|
|
EnclosureDownloadJob *downloadJob = new EnclosureDownloadJob(m_url, path(), m_entry->title());
|
|
downloadJob->start();
|
|
|
|
qint64 resumedAt = m_sizeOnDisk;
|
|
m_downloadProgress = 0;
|
|
Q_EMIT downloadProgressChanged();
|
|
|
|
m_entry->feed()->setErrorId(0);
|
|
m_entry->feed()->setErrorString(QString());
|
|
|
|
connect(downloadJob, &KJob::result, this, [this, downloadJob]() {
|
|
checkSizeOnDisk();
|
|
if (downloadJob->error() == 0) {
|
|
processDownloadedFile();
|
|
} else {
|
|
QFile file(path());
|
|
if (file.exists() && file.size() > 0) {
|
|
setStatus(PartiallyDownloaded);
|
|
} else {
|
|
setStatus(Downloadable);
|
|
}
|
|
if (downloadJob->error() != QNetworkReply::OperationCanceledError) {
|
|
m_entry->feed()->setErrorId(downloadJob->error());
|
|
m_entry->feed()->setErrorString(downloadJob->errorString());
|
|
Q_EMIT downloadError(Error::Type::MediaDownload,
|
|
m_entry->feed()->url(),
|
|
m_entry->id(),
|
|
downloadJob->error(),
|
|
downloadJob->errorString(),
|
|
QString());
|
|
}
|
|
}
|
|
disconnect(this, &Enclosure::cancelDownload, this, nullptr);
|
|
Q_EMIT statusChanged(m_entry, m_status);
|
|
});
|
|
|
|
connect(this, &Enclosure::cancelDownload, this, [this, downloadJob]() {
|
|
downloadJob->doKill();
|
|
checkSizeOnDisk();
|
|
QFile file(path());
|
|
if (file.exists() && file.size() > 0) {
|
|
setStatus(PartiallyDownloaded);
|
|
} else {
|
|
setStatus(Downloadable);
|
|
}
|
|
disconnect(this, &Enclosure::cancelDownload, this, nullptr);
|
|
});
|
|
|
|
connect(downloadJob, &KJob::processedAmountChanged, this, [=](KJob *kjob, KJob::Unit unit, qulonglong amount) {
|
|
Q_ASSERT(unit == KJob::Unit::Bytes);
|
|
|
|
qint64 totalSize = static_cast<qint64>(kjob->totalAmount(unit));
|
|
qint64 currentSize = static_cast<qint64>(amount);
|
|
|
|
if ((totalSize > 0) && (m_size != totalSize + resumedAt)) {
|
|
qCDebug(kastsEnclosure) << "Correct filesize for enclosure" << m_entry->title() << "from" << m_size << "to" << totalSize + resumedAt;
|
|
setSize(totalSize + resumedAt);
|
|
}
|
|
|
|
m_downloadSize = currentSize + resumedAt;
|
|
m_downloadProgress = static_cast<double>(m_downloadSize) / static_cast<double>(m_size);
|
|
Q_EMIT downloadProgressChanged();
|
|
|
|
qCDebug(kastsEnclosure) << "m_downloadSize" << m_downloadSize;
|
|
qCDebug(kastsEnclosure) << "m_downloadProgress" << m_downloadProgress;
|
|
qCDebug(kastsEnclosure) << "m_size" << m_size;
|
|
});
|
|
|
|
setStatus(Downloading);
|
|
}
|
|
|
|
void Enclosure::processDownloadedFile()
|
|
{
|
|
// This will be run if the enclosure has been downloaded successfully
|
|
|
|
// First check if file size is larger than 0; otherwise something unexpected
|
|
// must have happened
|
|
checkSizeOnDisk();
|
|
if (m_sizeOnDisk == 0) {
|
|
deleteFile();
|
|
return;
|
|
}
|
|
|
|
// Check if reported filesize in rss feed corresponds to real file size
|
|
// if not, correct the filesize in the database
|
|
// otherwise the file will get deleted because of mismatch in signature
|
|
if (m_sizeOnDisk != size()) {
|
|
qCDebug(kastsEnclosure) << "Correcting enclosure file size mismatch" << m_entry->title() << "from" << size() << "to" << m_sizeOnDisk;
|
|
setSize(m_sizeOnDisk);
|
|
setStatus(Downloaded);
|
|
}
|
|
|
|
// Unset "new" status of item
|
|
if (m_entry->getNew()) {
|
|
m_entry->setNew(false);
|
|
}
|
|
|
|
// Trigger update of image since the downloaded file can have an embedded image
|
|
Q_EMIT m_entry->imageChanged(m_entry->image());
|
|
Q_EMIT m_entry->cachedImageChanged(m_entry->cachedImage());
|
|
}
|
|
|
|
void Enclosure::deleteFile()
|
|
{
|
|
qCDebug(kastsEnclosure) << "Trying to delete enclosure file" << path();
|
|
if (AudioManager::instance().entry() && (m_entry == AudioManager::instance().entry())) {
|
|
qCDebug(kastsEnclosure) << "Track is still playing; let's unload it before deleting";
|
|
AudioManager::instance().setEntry(nullptr);
|
|
}
|
|
|
|
// First check if file still exists; you never know what has happened
|
|
if (QFile(path()).exists()) {
|
|
QFile(path()).remove();
|
|
}
|
|
|
|
// If file disappeared unexpectedly, then still change status to downloadable
|
|
setStatus(Downloadable);
|
|
m_sizeOnDisk = 0;
|
|
Q_EMIT sizeOnDiskChanged();
|
|
}
|
|
|
|
QString Enclosure::url() const
|
|
{
|
|
return m_url;
|
|
}
|
|
|
|
QString Enclosure::path() const
|
|
{
|
|
return StorageManager::instance().enclosurePath(m_entry->title(), m_url, m_entry->feed()->dirname());
|
|
}
|
|
|
|
Enclosure::Status Enclosure::status() const
|
|
{
|
|
return m_status;
|
|
}
|
|
|
|
QString Enclosure::cachedEmbeddedImage() const
|
|
{
|
|
// if image is already cached, then return the path
|
|
QString cachedpath = StorageManager::instance().imagePath(m_url);
|
|
if (QFileInfo::exists(cachedpath)) {
|
|
if (QFileInfo(cachedpath).size() != 0) {
|
|
return QUrl::fromLocalFile(cachedpath).toString();
|
|
}
|
|
}
|
|
|
|
if (m_status != Downloaded || path().isEmpty()) {
|
|
return QStringLiteral("");
|
|
}
|
|
|
|
const auto mime = QMimeDatabase().mimeTypeForFile(path()).name();
|
|
if (mime != QStringLiteral("audio/mpeg")) {
|
|
return QStringLiteral("");
|
|
}
|
|
|
|
TagLib::MPEG::File f(path().toLatin1().data());
|
|
if (!f.hasID3v2Tag()) {
|
|
return QStringLiteral("");
|
|
}
|
|
|
|
bool imageFound = false;
|
|
for (const auto &frame : f.ID3v2Tag()->frameListMap()["APIC"]) {
|
|
auto pictureFrame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frame);
|
|
QByteArray data(pictureFrame->picture().data(), pictureFrame->picture().size());
|
|
if (!data.isEmpty() && QImage().loadFromData(data)) {
|
|
QFile file(cachedpath);
|
|
file.open(QIODevice::WriteOnly);
|
|
file.write(data);
|
|
file.close();
|
|
imageFound = true;
|
|
}
|
|
}
|
|
|
|
if (imageFound) {
|
|
return cachedpath;
|
|
} else {
|
|
return QStringLiteral("");
|
|
}
|
|
}
|
|
|
|
qint64 Enclosure::playPosition() const
|
|
{
|
|
return m_playposition;
|
|
}
|
|
|
|
qint64 Enclosure::duration() const
|
|
{
|
|
return m_duration;
|
|
}
|
|
|
|
qint64 Enclosure::size() const
|
|
{
|
|
return m_size;
|
|
}
|
|
|
|
qint64 Enclosure::sizeOnDisk() const
|
|
{
|
|
return m_sizeOnDisk;
|
|
}
|
|
|
|
void Enclosure::setStatus(Enclosure::Status status)
|
|
{
|
|
if (m_status != status) {
|
|
m_status = status;
|
|
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("UPDATE Enclosures SET downloaded=:downloaded WHERE id=:id AND feed=:feed;"));
|
|
query.bindValue(QStringLiteral(":id"), m_entry->id());
|
|
query.bindValue(QStringLiteral(":feed"), m_entry->feed()->url());
|
|
query.bindValue(QStringLiteral(":downloaded"), statusToDb(m_status));
|
|
Database::instance().execute(query);
|
|
|
|
Q_EMIT statusChanged(m_entry, m_status);
|
|
}
|
|
}
|
|
|
|
void Enclosure::setPlayPosition(const qint64 &position)
|
|
{
|
|
if (m_playposition != position) {
|
|
m_playposition = position;
|
|
qCDebug(kastsEnclosure) << "save playPosition" << position << m_entry->title();
|
|
|
|
// let's only save the play position to the database every 15 seconds
|
|
if ((abs(m_playposition - m_playposition_dbsave) > 15000) || position == 0) {
|
|
qCDebug(kastsEnclosure) << "save playPosition to database" << position << m_entry->title();
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("UPDATE Enclosures SET playposition=:playposition WHERE id=:id AND feed=:feed"));
|
|
query.bindValue(QStringLiteral(":id"), m_entry->id());
|
|
query.bindValue(QStringLiteral(":feed"), m_entry->feed()->url());
|
|
query.bindValue(QStringLiteral(":playposition"), m_playposition);
|
|
Database::instance().execute(query);
|
|
m_playposition_dbsave = m_playposition;
|
|
|
|
// Also store position change to make sure that it can be synced to
|
|
// e.g. gpodder
|
|
Sync::instance().storePlayEpisodeAction(m_entry->id(), m_playposition_dbsave, m_playposition);
|
|
}
|
|
|
|
Q_EMIT playPositionChanged();
|
|
}
|
|
}
|
|
|
|
void Enclosure::setDuration(const qint64 &duration)
|
|
{
|
|
if (m_duration != duration) {
|
|
m_duration = duration;
|
|
|
|
// also save to database
|
|
qCDebug(kastsEnclosure) << "updating entry duration" << duration << m_entry->title();
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("UPDATE Enclosures SET duration=:duration WHERE id=:id AND feed=:feed"));
|
|
query.bindValue(QStringLiteral(":id"), m_entry->id());
|
|
query.bindValue(QStringLiteral(":feed"), m_entry->feed()->url());
|
|
query.bindValue(QStringLiteral(":duration"), m_duration);
|
|
Database::instance().execute(query);
|
|
|
|
Q_EMIT durationChanged();
|
|
}
|
|
}
|
|
|
|
void Enclosure::setSize(const qint64 &size)
|
|
{
|
|
if (m_size != size) {
|
|
m_size = size;
|
|
|
|
// also save to database
|
|
QSqlQuery query;
|
|
query.prepare(QStringLiteral("UPDATE Enclosures SET size=:size WHERE id=:id AND feed=:feed"));
|
|
query.bindValue(QStringLiteral(":id"), m_entry->id());
|
|
query.bindValue(QStringLiteral(":feed"), m_entry->feed()->url());
|
|
query.bindValue(QStringLiteral(":size"), m_size);
|
|
Database::instance().execute(query);
|
|
|
|
Q_EMIT sizeChanged();
|
|
}
|
|
}
|
|
|
|
void Enclosure::checkSizeOnDisk()
|
|
{
|
|
// In principle the database contains this status, we check anyway in case
|
|
// something changed on disk
|
|
QFile file(path());
|
|
if (file.exists()) {
|
|
if (file.size() == m_size && file.size() > 0) {
|
|
// file is on disk and has correct size, write to database if it
|
|
// wasn't already registered so
|
|
// this should, in principle, never happen unless the db was deleted
|
|
setStatus(Downloaded);
|
|
} else if (file.size() > 0) {
|
|
// file was downloaded, but there is a size mismatch
|
|
// set to PartiallyDownloaded such that download can be resumed
|
|
setStatus(PartiallyDownloaded);
|
|
} else {
|
|
// file is empty
|
|
setStatus(Downloadable);
|
|
}
|
|
if (file.size() != m_sizeOnDisk) {
|
|
m_sizeOnDisk = file.size();
|
|
m_downloadSize = m_sizeOnDisk;
|
|
m_downloadProgress = (m_size == 0) ? 0.0 : static_cast<double>(m_sizeOnDisk) / static_cast<double>(m_size);
|
|
Q_EMIT sizeOnDiskChanged();
|
|
}
|
|
} else {
|
|
// file does not exist
|
|
setStatus(Downloadable);
|
|
if (m_sizeOnDisk != 0) {
|
|
m_sizeOnDisk = 0;
|
|
m_downloadSize = 0;
|
|
m_downloadProgress = 0.0;
|
|
Q_EMIT sizeOnDiskChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
QString Enclosure::formattedSize() const
|
|
{
|
|
return m_kformat.formatByteSize(m_size);
|
|
}
|
|
|
|
QString Enclosure::formattedDownloadSize() const
|
|
{
|
|
return m_kformat.formatByteSize(m_downloadSize);
|
|
}
|
|
|
|
QString Enclosure::formattedDuration() const
|
|
{
|
|
return m_kformat.formatDuration(m_duration * 1000);
|
|
}
|
|
|
|
QString Enclosure::formattedLeftDuration() const
|
|
{
|
|
qreal rate = 1.0;
|
|
if (SettingsManager::self()->adjustTimeLeft()) {
|
|
rate = AudioManager::instance().playbackRate();
|
|
rate = (rate > 0.0) ? rate : 1.0;
|
|
}
|
|
qint64 diff = duration() * 1000 - playPosition();
|
|
return m_kformat.formatDuration(diff / rate);
|
|
}
|
|
|
|
QString Enclosure::formattedPlayPosition() const
|
|
{
|
|
return m_kformat.formatDuration(m_playposition);
|
|
}
|