kasts/src/enclosure.cpp

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);
}