Commit a15e2dbe authored by Bart De Vries's avatar Bart De Vries
Browse files

Make storage path configurable

This adds a new setting to the Settings page.
Existing enclosures and images will be moved to the new location
(first copied, then deleted in the original location).  If any of
the copy actions fail, the operation is aborted and the original
path is restored.
The StorageMoveJob is set up in such a way that it's easy to add other
files or subfolders in the future.

Solves #15
parent 85798ebd
Pipeline #71979 passed with stage
in 8 minutes and 28 seconds
......@@ -21,6 +21,8 @@ set(SRCS_base
errorlogmodel.cpp
error.cpp
podcastsearchmodel.cpp
storagemanager.cpp
storagemovejob.cpp
mpris2/mpris2.cpp
resources.qrc
)
......@@ -53,6 +55,13 @@ ecm_qt_declare_logging_category(SRCS_base
DEFAULT_SEVERITY Info
)
ecm_qt_declare_logging_category(SRCS_base
HEADER "storagemovejoblogging.h"
IDENTIFIER "kastsStorageMoveJob"
CATEGORY_NAME "org.kde.kasts.storagemovejob"
DEFAULT_SEVERITY Info
)
ecm_qt_declare_logging_category(SRCS_base
HEADER "feedlogging.h"
IDENTIFIER "kastsFeed"
......@@ -81,6 +90,13 @@ ecm_qt_declare_logging_category(SRCS_base
DEFAULT_SEVERITY Info
)
ecm_qt_declare_logging_category(SRCS_base
HEADER "storagemanagerlogging.h"
IDENTIFIER "kastsStorageManager"
CATEGORY_NAME "org.kde.kasts.storagemanager"
DEFAULT_SEVERITY Info
)
if(ANDROID)
set (SRCS ${SRCS_base}
androidlogging.h)
......
......@@ -234,7 +234,7 @@ void AudioManager::setEntry(Entry *entry)
+ QStringLiteral(" audio_sink=\"scaletempo ! audioconvert ! audioresample ! autoaudiosink\" video_sink=\"fakevideosink\"")));
#else
qCDebug(kastsAudio) << "regular audio backend";
d->m_player.setMedia(QUrl(QStringLiteral("file://") + d->m_entry->enclosure()->path()));
d->m_player.setMedia(QUrl::fromLocalFile(d->m_entry->enclosure()->path()));
#endif
// save the current playing track in the settingsfile for restoring on startup
DataManager::instance().setLastPlayingEntry(d->m_entry->id());
......
......@@ -6,13 +6,6 @@
#include "datamanager.h"
#include "audiomanager.h"
#include "database.h"
#include "datamanagerlogging.h"
#include "entry.h"
#include "feed.h"
#include "fetcher.h"
#include "settingsmanager.h"
#include <QDateTime>
#include <QDir>
#include <QSqlDatabase>
......@@ -22,6 +15,15 @@
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
#include "audiomanager.h"
#include "database.h"
#include "datamanagerlogging.h"
#include "entry.h"
#include "feed.h"
#include "fetcher.h"
#include "settingsmanager.h"
#include "storagemanager.h"
DataManager::DataManager()
{
connect(
......@@ -292,7 +294,7 @@ void DataManager::removeFeed(const int index)
if (getEntry(id)->hasEnclosure())
getEntry(id)->enclosure()->deleteFile(); // delete enclosure (if it exists)
if (!getEntry(id)->image().isEmpty())
Fetcher::instance().removeImage(getEntry(id)->image()); // delete entry images
StorageManager::instance().removeImage(getEntry(id)->image()); // delete entry images
delete m_entries[id]; // delete pointer
m_entries.remove(id); // delete the hash key
}
......@@ -300,7 +302,7 @@ void DataManager::removeFeed(const int index)
qCDebug(kastsDataManager) << "Remove feed image" << feed->image() << "for feed" << feedurl;
if (!feed->image().isEmpty())
Fetcher::instance().removeImage(feed->image());
StorageManager::instance().removeImage(feed->image());
m_feeds.remove(m_feedmap[index]); // remove from m_feeds
m_feedmap.removeAt(index); // remove from m_feedmap
delete feed; // remove the pointer
......
......@@ -23,6 +23,7 @@
#include "errorlogmodel.h"
#include "fetcher.h"
#include "settingsmanager.h"
#include "storagemanager.h"
Enclosure::Enclosure(Entry *entry)
: QObject(entry)
......@@ -216,7 +217,7 @@ void Enclosure::deleteFile()
QString Enclosure::path() const
{
return Fetcher::instance().enclosurePath(m_url);
return StorageManager::instance().enclosurePath(m_url);
}
Enclosure::Status Enclosure::status() const
......
......@@ -54,6 +54,8 @@ QString Error::description() const
return i18n("Invalid Media File");
case Error::Type::DiscoverError:
return i18n("Nothing Found");
case Error::Type::StorageMoveError:
return i18n("Error moving storage path");
default:
return QString();
}
......@@ -72,6 +74,8 @@ int Error::typeToDb(Error::Type type)
return 3;
case Error::Type::DiscoverError:
return 4;
case Error::Type::StorageMoveError:
return 5;
default:
return -1;
}
......@@ -90,6 +94,8 @@ Error::Type Error::dbToType(int value)
return Error::Type::InvalidMedia;
case 4:
return Error::Type::DiscoverError;
case 5:
return Error::Type::StorageMoveError;
default:
return Error::Type::Unknown;
}
......
......@@ -22,6 +22,7 @@ public:
MeteredNotAllowed,
InvalidMedia,
DiscoverError,
StorageMoveError,
};
Q_ENUM(Type)
......
......@@ -11,11 +11,13 @@
#include "database.h"
#include "datamanager.h"
#include "fetcher.h"
#include "storagemanager.h"
ErrorLogModel::ErrorLogModel()
: QAbstractListModel(nullptr)
{
connect(&Fetcher::instance(), &Fetcher::error, this, &ErrorLogModel::monitorErrorMessages);
connect(&StorageManager::instance(), &StorageManager::error, this, &ErrorLogModel::monitorErrorMessages);
QSqlQuery query;
query.prepare(QStringLiteral("SELECT * FROM Errors ORDER BY date DESC;"));
......
......@@ -5,8 +5,9 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "fetcher.h"
#include <KLocalizedString>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
......@@ -16,17 +17,16 @@
#include <QMultiMap>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QStandardPaths>
#include <QTextDocumentFragment>
#include <Syndication/Syndication>
#include "database.h"
#include "enclosure.h"
#include "fetcher.h"
#include "fetcherlogging.h"
#include "kasts-version.h"
#include "settingsmanager.h"
#include "storagemanager.h"
Fetcher::Fetcher()
{
......@@ -367,10 +367,10 @@ QString Fetcher::image(const QString &url) const
}
// if image is already cached, then return the path
QString path = imagePath(url);
QString path = StorageManager::instance().imagePath(url);
if (QFileInfo::exists(path)) {
if (QFileInfo(path).size() != 0) {
return QStringLiteral("file://") + path;
return QUrl::fromLocalFile(path).toString();
}
}
......@@ -450,28 +450,6 @@ QNetworkReply *Fetcher::download(const QString &url, const QString &filePath) co
return reply;
}
void Fetcher::removeImage(const QString &url)
{
qCDebug(kastsFetcher) << "Removing image" << imagePath(url);
QFile(imagePath(url)).remove();
}
QString Fetcher::imagePath(const QString &url) const
{
QString path = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/images/");
// Create path in cache if it doesn't exist yet
QFileInfo().absoluteDir().mkpath(path);
return path + QString::fromStdString(QCryptographicHash::hash(url.toUtf8(), QCryptographicHash::Md5).toHex().toStdString());
}
QString Fetcher::enclosurePath(const QString &url) const
{
QString path = QStandardPaths::writableLocation(QStandardPaths::DataLocation) + QStringLiteral("/enclosures/");
// Create path in cache if it doesn't exist yet
QFileInfo().absoluteDir().mkpath(path);
return path + QString::fromStdString(QCryptographicHash::hash(url.toUtf8(), QCryptographicHash::Md5).toHex().toStdString());
}
QNetworkReply *Fetcher::get(QNetworkRequest &request) const
{
setHeader(request);
......
......@@ -39,11 +39,8 @@ public:
Q_INVOKABLE void fetch(const QStringList &urls);
Q_INVOKABLE void fetchAll();
Q_INVOKABLE QString image(const QString &url) const;
void removeImage(const QString &url);
Q_INVOKABLE QNetworkReply *download(const QString &url, const QString &fileName) const;
QString imagePath(const QString &url) const;
QString enclosurePath(const QString &url) const;
QNetworkReply *get(QNetworkRequest &request) const;
// Network status related methods
......
......@@ -48,6 +48,7 @@
#include "podcastsearchmodel.h"
#include "queuemodel.h"
#include "settingsmanager.h"
#include "storagemanager.h"
#ifdef Q_OS_ANDROID
Q_DECL_EXPORT
......@@ -133,6 +134,7 @@ int main(int argc, char *argv[])
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "DownloadModel", &DownloadModel::instance());
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "ErrorLogModel", &ErrorLogModel::instance());
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "AudioManager", &AudioManager::instance());
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "StorageManager", &StorageManager::instance());
qRegisterMetaType<Entry *>("const Entry*"); // "hack" to make qml understand Entry*
qRegisterMetaType<Feed *>("const Feed*"); // "hack" to make qml understand Feed*
......
......@@ -11,7 +11,9 @@
#include "audiomanager.h"
#include "datamanager.h"
#include "fetcher.h"
#include "entry.h"
#include "feed.h"
#include "storagemanager.h"
#include <QCryptographicHash>
#include <QDBusConnection>
......@@ -388,7 +390,7 @@ QVariantMap MediaPlayer2Player::getMetadataOfCurrentTrack()
result[QStringLiteral("xesam:artist")] = authors;
}
if (!entry->image().isEmpty()) {
result[QStringLiteral("mpris:artUrl")] = Fetcher::instance().imagePath(entry->image());
result[QStringLiteral("mpris:artUrl")] = StorageManager::instance().imagePath(entry->image());
}
return result;
......
......@@ -14,6 +14,7 @@
class AudioManager;
class Entry;
class Feed;
class MediaPlayer2Player : public QDBusAbstractAdaptor
{
......
......@@ -5,8 +5,9 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick 2.14
import QtQuick.Controls 2.14 as Controls
import QtQuick 2.15
import QtQuick.Controls 2.15 as Controls
import Qt.labs.platform 1.1
import QtQuick.Layouts 1.14
import org.kde.kirigami 2.12 as Kirigami
......@@ -153,6 +154,68 @@ Kirigami.ScrollablePage {
onToggled: SettingsManager.articleFontUseSystem = checked
}
Kirigami.Heading {
Kirigami.FormData.isSection: true
text: i18n("Storage")
}
RowLayout {
visible: Qt.platform.os !== "android" // not functional on android
Kirigami.FormData.label: i18n("Storage path:")
Layout.fillWidth: true
Controls.TextField {
Layout.fillWidth: true
readOnly: true
text: StorageManager.storagePath
enabled: !defaultStoragePath.checked
}
Controls.Button {
icon.name: "document-open-folder"
text: i18n("Select folder...")
enabled: !defaultStoragePath.checked
onClicked: storagePathDialog.open()
}
FolderDialog {
id: storagePathDialog
title: i18n("Select Storage Path")
currentFolder: "file://" + StorageManager.storagePath
options: FolderDialog.ShowDirsOnly
onAccepted: {
StorageManager.setStoragePath(folder);
}
}
}
Controls.CheckBox {
id: defaultStoragePath
visible: Qt.platform.os !== "android" // not functional on android
checked: SettingsManager.storagePath == ""
text: i18n("Use default path")
onToggled: {
if (checked) {
StorageManager.setStoragePath("");
}
}
}
Controls.Label {
Kirigami.FormData.label: i18n("Podcast Downloads:")
text: i18nc("Using <amount of bytes> of disk space", "Using %1 of disk space", StorageManager.formattedEnclosureDirSize)
}
RowLayout {
Kirigami.FormData.label: i18n("Image Cache:")
Controls.Label {
text: i18nc("Using <amount of bytes> of disk space", "Using %1 of disk space", StorageManager.formattedImageDirSize)
}
Controls.Button {
icon.name: "edit-clear-all"
text: i18n("Clear Cache")
onClicked: StorageManager.clearImageCache();
}
}
Kirigami.Heading {
Kirigami.FormData.isSection: true
text: i18n("Errors")
......
......@@ -20,10 +20,21 @@ import org.kde.kasts 1.0
Rectangle {
id: rootComponent
required property string text
property bool showAbortButton: false
z: 2
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: bottomMessageSpacing + ( errorNotification.visible ? errorNotification.height : 0 )
}
color: Kirigami.Theme.activeTextColor
width: (labelWidth.boundingRect.width - labelWidth.boundingRect.x) + 3 * Kirigami.Units.largeSpacing +
indicator.width
width: feedUpdateCountLabel.width + 3 * Kirigami.Units.largeSpacing +
indicator.width + (showAbortButton ? abortButton.implicitWidth + Kirigami.Units.largeSpacing : 0)
height: indicator.height
visible: opacity > 0
......@@ -60,27 +71,25 @@ Rectangle {
Controls.Label {
id: feedUpdateCountLabel
text: i18ncp("Number of Updated Podcasts",
"Updated %2 of %1 Podcast",
"Updated %2 of %1 Podcasts",
Fetcher.updateTotal,
Fetcher.updateProgress)
text: rootComponent.text
color: Kirigami.Theme.textColor
Layout.fillWidth: true
//Layout.fillHeight: true
Layout.alignment: Qt.AlignVCenter
}
}
TextMetrics {
id: labelWidth
text: i18ncp("Number of Updated Podcasts",
"Updated %2 of %1 Podcast",
"Updated %2 of %1 Podcasts",
999,
999)
Controls.Button {
id: abortButton
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: Kirigami.Units.largeSpacing
visible: showAbortButton
Controls.ToolTip.visible: hovered
Controls.ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval
Controls.ToolTip.text: i18n("Abort")
text: i18n("Abort")
icon.name: "edit-delete-remove"
onClicked: abortAction();
}
}
Timer {
......@@ -102,15 +111,17 @@ Rectangle {
}
}
Connections {
target: Fetcher
function onUpdatingChanged() {
if (Fetcher.updating) {
hideTimer.stop()
opacity = 1
} else {
hideTimer.start()
}
}
function open() {
hideTimer.stop();
opacity = 1;
}
function close() {
hideTimer.start();
}
// if the abort button is enabled (showAbortButton = true), this function
// needs to be implemented/overriden to call the correct underlying
// method/function
function abortAction() {}
}
......@@ -239,13 +239,47 @@ Kirigami.ApplicationWindow {
// It mimicks the behaviour of an InlineMessage, because InlineMessage does
// not allow to add a BusyIndicator
UpdateNotification {
z: 2
id: updateNotification
text: i18ncp("Number of Updated Podcasts",
"Updated %2 of %1 Podcast",
"Updated %2 of %1 Podcasts",
Fetcher.updateTotal,
Fetcher.updateProgress)
Connections {
target: Fetcher
function onUpdatingChanged() {
if (Fetcher.updating) {
updateNotification.open()
} else {
updateNotification.close()
}
}
}
}
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: bottomMessageSpacing + ( errorNotification.visible ? errorNotification.height + Kirigami.Units.largeSpacing : 0 )
// Notification to show progress of copying enclosure and images to new location
UpdateNotification {
id: moveStorageNotification
text: i18ncp("Number of Moved Files",
"Moved %2 of %1 File",
"Moved %2 of %1 Files",
StorageManager.storageMoveTotal,
StorageManager.storageMoveProgress)
showAbortButton: true
function abortAction() {
StorageManager.cancelStorageMove();
}
Connections {
target: StorageManager
function onStorageMoveStarted() {
moveStorageNotification.open()
}
function onStorageMoveFinished() {
moveStorageNotification.close()
}
}
}
......
......@@ -56,6 +56,10 @@
<label>Use default system font</label>
<default>true</default>
</entry>
<entry name="StoragePath" type="Url">
<label>Custom path to store enclosures and images</label>
<default></default>
</entry>
</group>
<group name="Network">
<entry name="allowMeteredFeedUpdates" type="Bool">
......
/**
* 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 "storagemanager.h"
#include <KLocalizedString>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include "enclosure.h"
#include "settingsmanager.h"
#include "storagemanagerlogging.h"
#include "storagemovejob.h"
StorageManager::StorageManager()
{
}
QString StorageManager::storagePath() const
{
QString path = QStandardPaths::writableLocation(QStandardPaths::DataLocation);
if (!SettingsManager::self()->storagePath().isEmpty()) {
path = SettingsManager::self()->storagePath().toLocalFile();
}
// Create path if it doesn't exist yet
QFileInfo().absoluteDir().mkpath(path);
qCDebug(kastsStorageManager) << "Current storage path is" << path;
return path;
}
void StorageManager::setStoragePath(QUrl url)
{
qCDebug(kastsStorageManager) << "New storage path url:" << url;
QUrl oldUrl = SettingsManager::self()->storagePath();
QString oldPath = storagePath();
QString newPath = oldPath;
if (url.isEmpty()) {
qCDebug(kastsStorageManager) << "(Re)set storage path to default location";
SettingsManager::self()->setStoragePath(url);
newPath = storagePath(); // retrieve default storage path, since url is empty
} else if (url.isLocalFile()) {
SettingsManager::self()->setStoragePath(url);
newPath = url.toLocalFile();
} else {
qCDebug(kastsStorageManager) << "Cannot set storage path; path is not on local filesystem:" << url;
return;
}
qCDebug(kastsStorageManager) << "Current storage path in settings:" << SettingsManager::self()->storagePath();
qCDebug(kastsStorageManager) << "New storage path will be:" << newPath;
if (oldPath != newPath) {
QStringList list = {QStringLiteral("enclosures"), QStringLiteral("images")};
StorageMoveJob *moveJob = new StorageMoveJob(oldPath, newPath, list);
connect(moveJob, &KJob::processedAmountChanged, this, [this, moveJob]() {
m_storageMoveProgress = moveJob->processedAmount(KJob::Files);
Q_EMIT storageMoveProgressChanged(m_storageMoveProgress);
});
connect(moveJob, &KJob::totalAmountChanged, this, [this, moveJob]() {
m_storageMoveTotal = moveJob->totalAmount(KJob::Files);
Q_EMIT storageMoveTotalChanged(m_storageMoveTotal);
});
connect(moveJob, &KJob::result, this, [=]() {
if (moveJob->error() != 0) {
// Go back to previous old path
SettingsManager::self()->setStoragePath(oldUrl);
QString title =
i18n("Old location:") + QStringLiteral(" ") + oldPath + QStringLiteral("; ") + i18n("New location:") + QStringLiteral(" ") + newPath;
Q_EMIT error(Error::Type::StorageMoveError, QString(), QString(), moveJob->error(), moveJob->errorString(), title);
}
Q_EMIT storageMoveFinished();
Q_EMIT storagePathChanged(newPath);
// save settings now to avoid getting into an inconsistent app state
SettingsManager::self()->save();
disconnect(this, &StorageManager::cancelStorageMove, this, nullptr);
});