Add song fingerprinting and tracking

Fixes #296
This commit is contained in:
Jonas Kvinge 2021-04-25 21:16:44 +02:00
parent a883508eca
commit f8ed2afef1
40 changed files with 826 additions and 266 deletions

View File

@ -123,7 +123,7 @@ pkg_check_modules(GSTREAMER_PBUTILS gstreamer-pbutils-1.0)
pkg_check_modules(LIBVLC libvlc) pkg_check_modules(LIBVLC libvlc)
pkg_check_modules(SQLITE REQUIRED sqlite3>=3.9) pkg_check_modules(SQLITE REQUIRED sqlite3>=3.9)
pkg_check_modules(LIBPULSE libpulse) pkg_check_modules(LIBPULSE libpulse)
pkg_check_modules(CHROMAPRINT libchromaprint) pkg_check_modules(CHROMAPRINT libchromaprint>=1.4)
pkg_check_modules(LIBGPOD libgpod-1.0>=0.7.92) pkg_check_modules(LIBGPOD libgpod-1.0>=0.7.92)
pkg_check_modules(LIBMTP libmtp>=1.0) pkg_check_modules(LIBMTP libmtp>=1.0)
pkg_check_modules(GDK_PIXBUF gdk-pixbuf-2.0) pkg_check_modules(GDK_PIXBUF gdk-pixbuf-2.0)
@ -296,6 +296,11 @@ optional_component(VLC ON "Engine: VLC backend"
DEPENDS "libvlc" LIBVLC_FOUND DEPENDS "libvlc" LIBVLC_FOUND
) )
optional_component(SONGFINGERPRINTING ON "Song fingerprinting and tracking"
DEPENDS "chromaprint" CHROMAPRINT_FOUND
DEPENDS "gstreamer" GSTREAMER_FOUND
)
optional_component(MUSICBRAINZ ON "MusicBrainz integration" optional_component(MUSICBRAINZ ON "MusicBrainz integration"
DEPENDS "chromaprint" CHROMAPRINT_FOUND DEPENDS "chromaprint" CHROMAPRINT_FOUND
DEPENDS "gstreamer" GSTREAMER_FOUND DEPENDS "gstreamer" GSTREAMER_FOUND

View File

@ -14,6 +14,7 @@
<file>schema/schema-11.sql</file> <file>schema/schema-11.sql</file>
<file>schema/schema-12.sql</file> <file>schema/schema-12.sql</file>
<file>schema/schema-13.sql</file> <file>schema/schema-13.sql</file>
<file>schema/schema-14.sql</file>
<file>schema/device-schema.sql</file> <file>schema/device-schema.sql</file>
<file>style/strawberry.css</file> <file>style/strawberry.css</file>
<file>style/smartplaylistsearchterm.css</file> <file>style/smartplaylistsearchterm.css</file>

View File

@ -47,9 +47,12 @@ CREATE TABLE device_%deviceid_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,

View File

@ -0,0 +1,5 @@
ALTER TABLE %allsongstables ADD COLUMN fingerprint TEXT;
ALTER TABLE %allsongstables ADD COLUMN lastseen INTEGER NOT NULL DEFAULT -1;
UPDATE schema_version SET version=14;

View File

@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS schema_version (
DELETE FROM schema_version; DELETE FROM schema_version;
INSERT INTO schema_version (version) VALUES (13); INSERT INTO schema_version (version) VALUES (14);
CREATE TABLE IF NOT EXISTS directories ( CREATE TABLE IF NOT EXISTS directories (
path TEXT NOT NULL, path TEXT NOT NULL,
@ -55,9 +55,12 @@ CREATE TABLE IF NOT EXISTS songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -114,9 +117,12 @@ CREATE TABLE IF NOT EXISTS subsonic_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -173,9 +179,12 @@ CREATE TABLE IF NOT EXISTS tidal_artists_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -232,9 +241,12 @@ CREATE TABLE IF NOT EXISTS tidal_albums_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -291,9 +303,12 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -350,9 +365,12 @@ CREATE TABLE IF NOT EXISTS qobuz_artists_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -409,9 +427,12 @@ CREATE TABLE IF NOT EXISTS qobuz_albums_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -468,9 +489,12 @@ CREATE TABLE IF NOT EXISTS qobuz_songs (
ctime INTEGER NOT NULL DEFAULT -1, ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0, playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0, skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1, lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0, compilation_on INTEGER NOT NULL DEFAULT 0,
@ -547,9 +571,12 @@ CREATE TABLE IF NOT EXISTS playlist_items (
ctime INTEGER, ctime INTEGER,
unavailable INTEGER DEFAULT 0, unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER DEFAULT 0, playcount INTEGER DEFAULT 0,
skipcount INTEGER DEFAULT 0, skipcount INTEGER DEFAULT 0,
lastplayed INTEGER DEFAULT 0, lastplayed INTEGER DEFAULT -1,
lastseen INTEGER DEFAULT -1,
compilation_detected INTEGER DEFAULT 0, compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER DEFAULT 0, compilation_on INTEGER DEFAULT 0,

View File

@ -189,6 +189,7 @@ void TagReader::ReadFile(const QString &filename, spb::tagreader::SongMetadata *
#else #else
song->set_ctime(info.created().toSecsSinceEpoch()); song->set_ctime(info.created().toSecsSinceEpoch());
#endif #endif
song->set_lastseen(QDateTime::currentDateTime().toSecsSinceEpoch());
std::unique_ptr<TagLib::FileRef> fileref(factory_->GetFileRef(filename)); std::unique_ptr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (fileref->isNull()) { if (fileref->isNull()) {

View File

@ -60,10 +60,11 @@ message SongMetadata {
optional int32 playcount = 27; optional int32 playcount = 27;
optional int32 skipcount = 28; optional int32 skipcount = 28;
optional int32 lastplayed = 29; optional int64 lastplayed = 29;
optional int64 lastseen = 30;
optional bool suspicious_tags = 30; optional bool suspicious_tags = 31;
optional string art_automatic = 31; optional string art_automatic = 32;
} }

View File

@ -214,9 +214,6 @@ set(SOURCES
osd/osdbase.cpp osd/osdbase.cpp
osd/osdpretty.cpp osd/osdpretty.cpp
musicbrainz/acoustidclient.cpp
musicbrainz/musicbrainzclient.cpp
internet/internetservices.cpp internet/internetservices.cpp
internet/internetservice.cpp internet/internetservice.cpp
internet/internetplaylistitem.cpp internet/internetplaylistitem.cpp
@ -429,9 +426,6 @@ set(HEADERS
osd/osdbase.h osd/osdbase.h
osd/osdpretty.h osd/osdpretty.h
musicbrainz/acoustidclient.h
musicbrainz/musicbrainzclient.h
internet/internetservices.h internet/internetservices.h
internet/internetservice.h internet/internetservice.h
internet/internetsongmimedata.h internet/internetsongmimedata.h
@ -831,7 +825,7 @@ optional_source(HAVE_LIBPULSE
engine/pulsedevicefinder.cpp engine/pulsedevicefinder.cpp
) )
# MusicBrainz and transcoder require GStreamer # Transcoder require GStreamer
optional_source(HAVE_GSTREAMER optional_source(HAVE_GSTREAMER
SOURCES SOURCES
transcoder/transcoder.cpp transcoder/transcoder.cpp
@ -867,12 +861,18 @@ UI
settings/transcodersettingspage.ui settings/transcodersettingspage.ui
) )
# CHROMAPRINT
optional_source(CHROMAPRINT_FOUND SOURCES engine/chromaprinter.cpp)
# MusicBrainz # MusicBrainz
optional_source(HAVE_MUSICBRAINZ optional_source(HAVE_MUSICBRAINZ
SOURCES SOURCES
musicbrainz/chromaprinter.cpp musicbrainz/acoustidclient.cpp
musicbrainz/musicbrainzclient.cpp
musicbrainz/tagfetcher.cpp musicbrainz/tagfetcher.cpp
HEADERS HEADERS
musicbrainz/acoustidclient.h
musicbrainz/musicbrainzclient.h
musicbrainz/tagfetcher.h musicbrainz/tagfetcher.h
) )
@ -1093,9 +1093,9 @@ if(HAVE_VLC)
link_directories(${LIBVLC_LIBRARY_DIRS}) link_directories(${LIBVLC_LIBRARY_DIRS})
endif() endif()
if(HAVE_MUSICBRAINZ) if(CHROMAPRINT_FOUND)
link_directories(${CHROMAPRINT_LIBRARY_DIRS}) link_directories(${CHROMAPRINT_LIBRARY_DIRS})
endif(HAVE_MUSICBRAINZ) endif(CHROMAPRINT_FOUND)
if(X11_FOUND) if(X11_FOUND)
link_directories(${X11_LIBRARY_DIRS}) link_directories(${X11_LIBRARY_DIRS})
@ -1211,10 +1211,10 @@ if(HAVE_VLC)
target_link_libraries(strawberry_lib PRIVATE ${LIBVLC_LIBRARIES}) target_link_libraries(strawberry_lib PRIVATE ${LIBVLC_LIBRARIES})
endif() endif()
if(HAVE_MUSICBRAINZ) if(CHROMAPRINT_FOUND)
target_include_directories(strawberry_lib SYSTEM PRIVATE ${CHROMAPRINT_INCLUDE_DIRS}) target_include_directories(strawberry_lib SYSTEM PRIVATE ${CHROMAPRINT_INCLUDE_DIRS})
target_link_libraries(strawberry_lib PRIVATE ${CHROMAPRINT_LIBRARIES}) target_link_libraries(strawberry_lib PRIVATE ${CHROMAPRINT_LIBRARIES})
endif(HAVE_MUSICBRAINZ) endif(CHROMAPRINT_FOUND)
if(X11_FOUND) if(X11_FOUND)
target_include_directories(strawberry_lib SYSTEM PRIVATE ${X11_INCLUDE_DIR}) target_include_directories(strawberry_lib SYSTEM PRIVATE ${X11_INCLUDE_DIR})

View File

@ -107,6 +107,7 @@ void SCollection::Init() {
QObject::connect(watcher_, &CollectionWatcher::SubdirsDiscovered, backend_, &CollectionBackend::AddOrUpdateSubdirs); QObject::connect(watcher_, &CollectionWatcher::SubdirsDiscovered, backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(watcher_, &CollectionWatcher::SubdirsMTimeUpdated, backend_, &CollectionBackend::AddOrUpdateSubdirs); QObject::connect(watcher_, &CollectionWatcher::SubdirsMTimeUpdated, backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(watcher_, &CollectionWatcher::CompilationsNeedUpdating, backend_, &CollectionBackend::CompilationsNeedUpdating); QObject::connect(watcher_, &CollectionWatcher::CompilationsNeedUpdating, backend_, &CollectionBackend::CompilationsNeedUpdating);
QObject::connect(watcher_, &CollectionWatcher::UpdateLastSeen, backend_, &CollectionBackend::UpdateLastSeen);
QObject::connect(app_->lastfm_import(), &LastFMImport::UpdateLastPlayed, backend_, &CollectionBackend::UpdateLastPlayed); QObject::connect(app_->lastfm_import(), &LastFMImport::UpdateLastPlayed, backend_, &CollectionBackend::UpdateLastPlayed);
QObject::connect(app_->lastfm_import(), &LastFMImport::UpdatePlayCount, backend_, &CollectionBackend::UpdatePlayCount); QObject::connect(app_->lastfm_import(), &LastFMImport::UpdatePlayCount, backend_, &CollectionBackend::UpdatePlayCount);

View File

@ -348,6 +348,27 @@ SongList CollectionBackend::FindSongsInDirectory(const int id) {
} }
SongList CollectionBackend::SongsWithMissingFingerprint(const int id) {
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare(QString("SELECT ROWID, " + Song::kColumnSpec + " FROM %1 WHERE directory_id = :directory_id AND unavailable = 0 AND fingerprint = ''").arg(songs_table_));
q.bindValue(":directory_id", id);
q.exec();
if (db_->CheckErrors(q)) return SongList();
SongList ret;
while (q.next()) {
Song song(source_);
song.InitFromQuery(q, true);
ret << song;
}
return ret;
}
void CollectionBackend::SongPathChanged(const Song &song, const QFileInfo &new_file) { void CollectionBackend::SongPathChanged(const Song &song, const QFileInfo &new_file) {
// Take a song and update its path // Take a song and update its path
@ -914,6 +935,30 @@ SongList CollectionBackend::GetSongsBySongId(const QStringList &song_ids, QSqlDa
} }
SongList CollectionBackend::GetSongsByFingerprint(const QString &fingerprint) {
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare(QString("SELECT ROWID, " + Song::kColumnSpec + " FROM %1 WHERE fingerprint = :fingerprint").arg(songs_table_));
q.bindValue(":fingerprint", fingerprint);
q.exec();
if (db_->CheckErrors(q)) return SongList();
SongList songs;
while (q.next()) {
Song song(source_);
song.InitFromQuery(q, true);
songs << song;
}
return songs;
}
CollectionBackend::AlbumList CollectionBackend::GetCompilationAlbums(const QueryOptions &opt) { CollectionBackend::AlbumList CollectionBackend::GetCompilationAlbums(const QueryOptions &opt) {
return GetAlbums(QString(), true, opt); return GetAlbums(QString(), true, opt);
} }
@ -1546,3 +1591,44 @@ void CollectionBackend::UpdateSongRatingAsync(const int id, const double rating)
void CollectionBackend::UpdateSongsRatingAsync(const QList<int>& ids, const double rating) { void CollectionBackend::UpdateSongsRatingAsync(const QList<int>& ids, const double rating) {
metaObject()->invokeMethod(this, "UpdateSongsRating", Qt::QueuedConnection, Q_ARG(QList<int>, ids), Q_ARG(double, rating)); metaObject()->invokeMethod(this, "UpdateSongsRating", Qt::QueuedConnection, Q_ARG(QList<int>, ids), Q_ARG(double, rating));
} }
void CollectionBackend::UpdateLastSeen(const int directory_id, const int expire_unavailable_songs_days) {
{
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare(QString("UPDATE %1 SET lastseen = :lastseen WHERE directory_id = :directory_id AND unavailable = 0").arg(songs_table_));
q.bindValue(":lastseen", QDateTime::currentDateTime().toSecsSinceEpoch());
q.bindValue(":directory_id", directory_id);
q.exec();
db_->CheckErrors(q);
}
if (expire_unavailable_songs_days > 0) ExpireSongs(directory_id, expire_unavailable_songs_days);
}
void CollectionBackend::ExpireSongs(const int directory_id, const int expire_unavailable_songs_days) {
SongList songs;
{
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare(QString("SELECT ROWID, " + Song::kColumnSpec + " FROM %1 WHERE directory_id = :directory_id AND unavailable = 1 AND lastseen > 0 AND lastseen < :time").arg(songs_table_));
q.bindValue(":directory_id", directory_id);
q.bindValue(":time", QDateTime::currentDateTime().toSecsSinceEpoch() - (expire_unavailable_songs_days * 86400));
q.exec();
if (db_->CheckErrors(q)) return;
while (q.next()) {
Song song(source_);
song.InitFromQuery(q, true);
songs << song;
}
}
if (!songs.isEmpty()) DeleteSongs(songs);
}

View File

@ -83,6 +83,7 @@ class CollectionBackendInterface : public QObject {
virtual void UpdateTotalAlbumCountAsync() = 0; virtual void UpdateTotalAlbumCountAsync() = 0;
virtual SongList FindSongsInDirectory(const int id) = 0; virtual SongList FindSongsInDirectory(const int id) = 0;
virtual SongList SongsWithMissingFingerprint(const int id) = 0;
virtual SubdirectoryList SubdirsInDirectory(const int id) = 0; virtual SubdirectoryList SubdirsInDirectory(const int id) = 0;
virtual DirectoryList GetAllDirectories() = 0; virtual DirectoryList GetAllDirectories() = 0;
virtual void ChangeDirPath(const int id, const QString &old_path, const QString &new_path) = 0; virtual void ChangeDirPath(const int id, const QString &old_path, const QString &new_path) = 0;
@ -106,6 +107,8 @@ class CollectionBackendInterface : public QObject {
virtual Song GetSongById(const int id) = 0; virtual Song GetSongById(const int id) = 0;
virtual SongList GetSongsByFingerprint(const QString &fingerprint) = 0;
// Returns all sections of a song with the given filename. If there's just one section the resulting list will have it's size equal to 1. // Returns all sections of a song with the given filename. If there's just one section the resulting list will have it's size equal to 1.
virtual SongList GetSongsByUrl(const QUrl &url, const bool unavailable = false) = 0; virtual SongList GetSongsByUrl(const QUrl &url, const bool unavailable = false) = 0;
// Returns a section of a song with the given filename and beginning. If the section is not present in collection, returns invalid song. // Returns a section of a song with the given filename and beginning. If the section is not present in collection, returns invalid song.
@ -143,6 +146,7 @@ class CollectionBackend : public CollectionBackendInterface {
void UpdateTotalAlbumCountAsync() override; void UpdateTotalAlbumCountAsync() override;
SongList FindSongsInDirectory(const int id) override; SongList FindSongsInDirectory(const int id) override;
SongList SongsWithMissingFingerprint(const int id) override;
SubdirectoryList SubdirsInDirectory(const int id) override; SubdirectoryList SubdirsInDirectory(const int id) override;
DirectoryList GetAllDirectories() override; DirectoryList GetAllDirectories() override;
void ChangeDirPath(const int id, const QString &old_path, const QString &new_path) override; void ChangeDirPath(const int id, const QString &old_path, const QString &new_path) override;
@ -187,6 +191,8 @@ class CollectionBackend : public CollectionBackendInterface {
Song GetSongBySongId(const QString &song_id); Song GetSongBySongId(const QString &song_id);
SongList GetSongsBySongId(const QStringList &song_ids); SongList GetSongsBySongId(const QStringList &song_ids);
SongList GetSongsByFingerprint(const QString &fingerprint) override;
SongList GetAllSongs(); SongList GetAllSongs();
SongList FindSongs(const SmartPlaylistSearch &search); SongList FindSongs(const SmartPlaylistSearch &search);
@ -224,6 +230,9 @@ class CollectionBackend : public CollectionBackendInterface {
void UpdateSongRating(const int id, const double rating); void UpdateSongRating(const int id, const double rating);
void UpdateSongsRating(const QList<int> &id_list, const double rating); void UpdateSongsRating(const QList<int> &id_list, const double rating);
void UpdateLastSeen(const int directory_id, const int expire_unavailable_songs_days);
void ExpireSongs(const int directory_id, const int expire_unavailable_songs_days);
signals: signals:
void DirectoryDiscovered(Directory, SubdirectoryList); void DirectoryDiscovered(Directory, SubdirectoryList);
void DirectoryDeleted(Directory); void DirectoryDeleted(Directory);

View File

@ -47,13 +47,18 @@
#include "core/filesystemwatcherinterface.h" #include "core/filesystemwatcherinterface.h"
#include "core/logging.h" #include "core/logging.h"
#include "core/timeconstants.h"
#include "core/tagreaderclient.h" #include "core/tagreaderclient.h"
#include "core/taskmanager.h" #include "core/taskmanager.h"
#include "core/imageutils.h"
#include "directory.h" #include "directory.h"
#include "collectionbackend.h" #include "collectionbackend.h"
#include "collectionwatcher.h" #include "collectionwatcher.h"
#include "playlistparsers/cueparser.h" #include "playlistparsers/cueparser.h"
#include "settings/collectionsettingspage.h" #include "settings/collectionsettingspage.h"
#ifdef HAVE_SONGFINGERPRINTING
# include "engine/chromaprinter.h"
#endif
// This is defined by one of the windows headers that is included by taglib. // This is defined by one of the windows headers that is included by taglib.
#ifdef RemoveDirectory #ifdef RemoveDirectory
@ -73,25 +78,40 @@ CollectionWatcher::CollectionWatcher(Song::Source source, QObject *parent)
backend_(nullptr), backend_(nullptr),
task_manager_(nullptr), task_manager_(nullptr),
fs_watcher_(FileSystemWatcherInterface::Create(this)), fs_watcher_(FileSystemWatcherInterface::Create(this)),
original_thread_(nullptr),
scan_on_startup_(true), scan_on_startup_(true),
monitor_(true), monitor_(true),
mark_songs_unavailable_(false), song_tracking_(true),
mark_songs_unavailable_(true),
expire_unavailable_songs_days_(60),
stop_requested_(false), stop_requested_(false),
rescan_in_progress_(false), rescan_in_progress_(false),
rescan_timer_(new QTimer(this)), rescan_timer_(new QTimer(this)),
periodic_scan_timer_(new QTimer(this)),
rescan_paused_(false), rescan_paused_(false),
total_watches_(0), total_watches_(0),
cue_parser_(new CueParser(backend_, this)), cue_parser_(new CueParser(backend_, this)),
original_thread_(nullptr) { last_scan_time_(0) {
original_thread_ = thread(); original_thread_ = thread();
rescan_timer_->setInterval(1000); rescan_timer_->setInterval(2000);
rescan_timer_->setSingleShot(true); rescan_timer_->setSingleShot(true);
periodic_scan_timer_->setInterval(86400 * kMsecPerSec);
periodic_scan_timer_->setSingleShot(false);
QStringList image_formats = ImageUtils::SupportedImageFormats();
for (const QString &format : image_formats) {
if (!sValidImages.contains(format)) {
sValidImages.append(format);
}
}
ReloadSettings(); ReloadSettings();
QObject::connect(rescan_timer_, &QTimer::timeout, this, &CollectionWatcher::RescanPathsNow); QObject::connect(rescan_timer_, &QTimer::timeout, this, &CollectionWatcher::RescanPathsNow);
QObject::connect(periodic_scan_timer_, &QTimer::timeout, this, &CollectionWatcher::IncrementalScanCheck);
} }
@ -123,8 +143,10 @@ void CollectionWatcher::ReloadSettings() {
s.beginGroup(CollectionSettingsPage::kSettingsGroup); s.beginGroup(CollectionSettingsPage::kSettingsGroup);
scan_on_startup_ = s.value("startup_scan", true).toBool(); scan_on_startup_ = s.value("startup_scan", true).toBool();
monitor_ = s.value("monitor", true).toBool(); monitor_ = s.value("monitor", true).toBool();
mark_songs_unavailable_ = s.value("mark_songs_unavailable", false).toBool();
QStringList filters = s.value("cover_art_patterns", QStringList() << "front" << "cover").toStringList(); QStringList filters = s.value("cover_art_patterns", QStringList() << "front" << "cover").toStringList();
song_tracking_ = s.value("song_tracking", false).toBool();
mark_songs_unavailable_ = song_tracking_ ? true : s.value("mark_songs_unavailable", true).toBool();
expire_unavailable_songs_days_ = s.value("expire_unavailable_songs", 60).toInt();
s.endGroup(); s.endGroup();
best_image_filters_.clear(); best_image_filters_.clear();
@ -147,6 +169,13 @@ void CollectionWatcher::ReloadSettings() {
} }
} }
if (mark_songs_unavailable_ && !periodic_scan_timer_->isActive()) {
periodic_scan_timer_->start();
}
else if (!mark_songs_unavailable_ && periodic_scan_timer_->isActive()) {
periodic_scan_timer_->stop();
}
} }
CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher, const int dir, const bool incremental, const bool ignores_mtime, const bool mark_songs_unavailable) CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher, const int dir, const bool incremental, const bool ignores_mtime, const bool mark_songs_unavailable)
@ -156,8 +185,10 @@ CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher,
incremental_(incremental), incremental_(incremental),
ignores_mtime_(ignores_mtime), ignores_mtime_(ignores_mtime),
mark_songs_unavailable_(mark_songs_unavailable), mark_songs_unavailable_(mark_songs_unavailable),
expire_unavailable_songs_days_(60),
watcher_(watcher), watcher_(watcher),
cached_songs_dirty_(true), cached_songs_dirty_(true),
cached_songs_missing_fingerprint_dirty_(true),
known_subdirs_dirty_(true) { known_subdirs_dirty_(true) {
QString description; QString description;
@ -185,14 +216,14 @@ CollectionWatcher::ScanTransaction::~ScanTransaction() {
} }
void CollectionWatcher::ScanTransaction::AddToProgress(int n) { void CollectionWatcher::ScanTransaction::AddToProgress(const quint64 n) {
progress_ += n; progress_ += n;
watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_); watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_);
} }
void CollectionWatcher::ScanTransaction::AddToProgressMax(int n) { void CollectionWatcher::ScanTransaction::AddToProgressMax(const quint64 n) {
progress_max_ += n; progress_max_ += n;
watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_); watcher_->task_manager_->SetTaskProgress(task_id_, progress_, progress_max_);
@ -201,16 +232,6 @@ void CollectionWatcher::ScanTransaction::AddToProgressMax(int n) {
void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() { void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
if (!new_songs.isEmpty()) {
emit watcher_->NewOrUpdatedSongs(new_songs);
new_songs.clear();
}
if (!touched_songs.isEmpty()) {
emit watcher_->SongsMTimeUpdated(touched_songs);
touched_songs.clear();
}
if (!deleted_songs.isEmpty()) { if (!deleted_songs.isEmpty()) {
if (mark_songs_unavailable_) { if (mark_songs_unavailable_) {
emit watcher_->SongsUnavailable(deleted_songs); emit watcher_->SongsUnavailable(deleted_songs);
@ -221,6 +242,16 @@ void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
deleted_songs.clear(); deleted_songs.clear();
} }
if (!new_songs.isEmpty()) {
emit watcher_->NewOrUpdatedSongs(new_songs);
new_songs.clear();
}
if (!touched_songs.isEmpty()) {
emit watcher_->SongsMTimeUpdated(touched_songs);
touched_songs.clear();
}
if (!readded_songs.isEmpty()) { if (!readded_songs.isEmpty()) {
emit watcher_->SongsReadded(readded_songs); emit watcher_->SongsReadded(readded_songs);
readded_songs.clear(); readded_songs.clear();
@ -252,22 +283,43 @@ void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
} }
new_subdirs.clear(); new_subdirs.clear();
if (incremental_ || ignores_mtime_) {
emit watcher_->UpdateLastSeen(dir_, expire_unavailable_songs_days_);
}
} }
SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QString &path) { SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QString &path) {
if (cached_songs_dirty_) { if (cached_songs_dirty_) {
cached_songs_ = watcher_->backend_->FindSongsInDirectory(dir_); const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_);
for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section('/', 0, -2);
cached_songs_.insert(p, song);
}
cached_songs_dirty_ = false; cached_songs_dirty_ = false;
} }
// TODO: Make this faster if (cached_songs_.contains(path)) {
SongList ret; return cached_songs_.values(path);
for (const Song &song : cached_songs_) {
if (song.url().toLocalFile().section('/', 0, -2) == path) ret << song;
} }
return ret; else return SongList();
}
bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QString &path) {
if (cached_songs_missing_fingerprint_dirty_) {
const SongList songs = watcher_->backend_->SongsWithMissingFingerprint(dir_);
for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section('/', 0, -2);
cached_songs_missing_fingerprint_.insert(p, song);
}
cached_songs_missing_fingerprint_dirty_ = false;
}
return cached_songs_missing_fingerprint_.contains(path);
} }
@ -292,8 +344,9 @@ bool CollectionWatcher::ScanTransaction::HasSeenSubdir(const QString &path) {
SubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const QString &path) { SubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const QString &path) {
if (known_subdirs_dirty_) if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_)); SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
}
SubdirectoryList ret; SubdirectoryList ret;
for (const Subdirectory &subdir : known_subdirs_) { for (const Subdirectory &subdir : known_subdirs_) {
@ -308,9 +361,12 @@ SubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const Q
SubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() { SubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() {
if (known_subdirs_dirty_) if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_)); SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
}
return known_subdirs_; return known_subdirs_;
} }
void CollectionWatcher::AddDirectory(const Directory &dir, const SubdirectoryList &subdirs) { void CollectionWatcher::AddDirectory(const Directory &dir, const SubdirectoryList &subdirs) {
@ -320,29 +376,36 @@ void CollectionWatcher::AddDirectory(const Directory &dir, const SubdirectoryLis
if (subdirs.isEmpty()) { if (subdirs.isEmpty()) {
// This is a new directory that we've never seen before. Scan it fully. // This is a new directory that we've never seen before. Scan it fully.
ScanTransaction transaction(this, dir.id, false, false, mark_songs_unavailable_); ScanTransaction transaction(this, dir.id, false, false, mark_songs_unavailable_);
const quint64 files_count = FilesCountForPath(&transaction, dir.path);
transaction.SetKnownSubdirs(subdirs); transaction.SetKnownSubdirs(subdirs);
transaction.AddToProgressMax(1); transaction.AddToProgressMax(files_count);
ScanSubdirectory(dir.path, Subdirectory(), &transaction); ScanSubdirectory(dir.path, Subdirectory(), files_count, &transaction);
last_scan_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
} }
else { else {
// We can do an incremental scan - looking at the mtimes of each subdirectory and only rescan if the directory has changed. // We can do an incremental scan - looking at the mtimes of each subdirectory and only rescan if the directory has changed.
ScanTransaction transaction(this, dir.id, true, false, mark_songs_unavailable_); ScanTransaction transaction(this, dir.id, true, false, mark_songs_unavailable_);
QMap<QString, quint64> subdir_files_count;
const quint64 files_count = FilesCountForSubdirs(&transaction, subdirs, subdir_files_count);
transaction.SetKnownSubdirs(subdirs); transaction.SetKnownSubdirs(subdirs);
transaction.AddToProgressMax(subdirs.count()); transaction.AddToProgressMax(files_count);
for (const Subdirectory &subdir : subdirs) { for (const Subdirectory &subdir : subdirs) {
if (stop_requested_) break; if (stop_requested_) break;
if (scan_on_startup_) ScanSubdirectory(subdir.path, subdir, &transaction); if (scan_on_startup_) ScanSubdirectory(subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
if (monitor_) AddWatch(dir, subdir.path); if (monitor_) AddWatch(dir, subdir.path);
} }
last_scan_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
} }
emit CompilationsNeedUpdating(); emit CompilationsNeedUpdating();
} }
void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory &subdir, ScanTransaction *t, bool force_noincremental) { void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
QFileInfo path_info(path); QFileInfo path_info(path);
QDir path_dir(path); QDir path_dir(path);
@ -352,7 +415,6 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
QString real_path = path_info.symLinkTarget(); QString real_path = path_info.symLinkTarget();
for (const Directory &dir : qAsConst(watched_dirs_)) { for (const Directory &dir : qAsConst(watched_dirs_)) {
if (real_path.startsWith(dir.path)) { if (real_path.startsWith(dir.path)) {
t->AddToProgress(1);
return; return;
} }
} }
@ -360,13 +422,19 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
// Do not scan directories containing a .nomedia or .nomusic file // Do not scan directories containing a .nomedia or .nomusic file
if (path_dir.exists(kNoMediaFile) || path_dir.exists(kNoMusicFile)) { if (path_dir.exists(kNoMediaFile) || path_dir.exists(kNoMusicFile)) {
t->AddToProgress(1);
return; return;
} }
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && subdir.mtime == path_info.lastModified().toSecsSinceEpoch()) { bool songs_missing_fingerprint = false;
#ifdef HAVE_SONGFINGERPRINTING
if (song_tracking_) {
songs_missing_fingerprint = t->HasSongsWithMissingFingerprint(path);
}
#endif
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && subdir.mtime == path_info.lastModified().toSecsSinceEpoch() && !songs_missing_fingerprint) {
// The directory hasn't changed since last time // The directory hasn't changed since last time
t->AddToProgress(1); t->AddToProgress(files_count);
return; return;
} }
@ -379,8 +447,7 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
SubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path); SubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path);
for (const Subdirectory &prev_subdir : previous_subdirs) { for (const Subdirectory &prev_subdir : previous_subdirs) {
if (!QFile::exists(prev_subdir.path) && prev_subdir.path != path) { if (!QFile::exists(prev_subdir.path) && prev_subdir.path != path) {
t->AddToProgressMax(1); ScanSubdirectory(prev_subdir.path, prev_subdir, 0, t, true);
ScanSubdirectory(prev_subdir.path, prev_subdir, t, true);
} }
} }
@ -402,15 +469,21 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
new_subdir.mtime = child_info.lastModified().toSecsSinceEpoch(); new_subdir.mtime = child_info.lastModified().toSecsSinceEpoch();
my_new_subdirs << new_subdir; my_new_subdirs << new_subdir;
} }
t->AddToProgress(1);
} }
else { else {
QString ext_part(ExtensionPart(child)); QString ext_part(ExtensionPart(child));
QString dir_part(DirectoryPart(child)); QString dir_part(DirectoryPart(child));
if (sValidImages.contains(ext_part)) {
if (sValidImages.contains(ext_part))
album_art[dir_part] << child; album_art[dir_part] << child;
else if (!child_info.isHidden()) t->AddToProgress(1);
}
else if (TagReaderClient::Instance()->IsMediaFileBlocking(child)) {
files_on_disk << child; files_on_disk << child;
}
else {
t->AddToProgress(1);
}
} }
} }
@ -422,15 +495,18 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
QSet<QString> cues_processed; QSet<QString> cues_processed;
// Now compare the list from the database with the list of files on disk // Now compare the list from the database with the list of files on disk
for (const QString &file : files_on_disk) { QStringList files_on_disk_copy = files_on_disk;
for (const QString &file : files_on_disk_copy) {
if (stop_requested_) return; if (stop_requested_) return;
// Associated cue // Associated CUE
QString matching_cue = NoExtensionPart(file) + ".cue"; QString new_cue = NoExtensionPart(file) + ".cue";
Song matching_song(source_); SongList matching_songs;
if (FindSongByPath(songs_in_db, file, &matching_song)) { if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path.
qint64 matching_cue_mtime = GetMtimeForCue(matching_cue);
Song matching_song = matching_songs.first();
// The song is in the database and still on disk. // The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added. // Check the mtime to see if it's been changed since it was added.
@ -439,18 +515,21 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
if (!file_info.exists()) { if (!file_info.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now. // Partially fixes race condition - if file was removed between being added to the list and now.
files_on_disk.removeAll(file); files_on_disk.removeAll(file);
t->AddToProgress(1);
continue; continue;
} }
// CUE sheet's path from collection (if any) // CUE sheet's path from collection (if any).
QString song_cue = matching_song.cue_path(); qint64 matching_song_cue_mtime = GetMtimeForCue(matching_song.cue_path());
qint64 song_cue_mtime = GetMtimeForCue(song_cue);
bool cue_deleted = song_cue_mtime == 0 && matching_song.has_cue(); // CUE sheet's path from this file (if any).
bool cue_added = matching_cue_mtime != 0 && !matching_song.has_cue(); qint64 new_cue_mtime = GetMtimeForCue(new_cue);
bool cue_added = new_cue_mtime != 0 && !matching_song.has_cue();
bool cue_deleted = matching_song_cue_mtime == 0 && matching_song.has_cue();
// Watch out for CUE songs which have their mtime equal to qMax(media_file_mtime, cue_sheet_mtime) // Watch out for CUE songs which have their mtime equal to qMax(media_file_mtime, cue_sheet_mtime)
bool changed = (matching_song.mtime() != qMax(file_info.lastModified().toSecsSinceEpoch(), song_cue_mtime)) || cue_deleted || cue_added; bool changed = (matching_song.mtime() != qMax(file_info.lastModified().toSecsSinceEpoch(), matching_song_cue_mtime)) || cue_deleted || cue_added;
// Also want to look to see whether the album art has changed // Also want to look to see whether the album art has changed
QUrl image = ImageForSong(file, album_art); QUrl image = ImageForSong(file, album_art);
@ -458,53 +537,132 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
changed = true; changed = true;
} }
// The song's changed - reread the metadata from file bool missing_fingerprint = false;
if (t->ignores_mtime() || changed) { #ifdef HAVE_SONGFINGERPRINTING
qLog(Debug) << file << "changed"; if (song_tracking_ && matching_song.fingerprint().isEmpty()) {
missing_fingerprint = true;
}
#endif
if (changed) {
qLog(Debug) << file << "has changed.";
}
else if (missing_fingerprint) {
qLog(Debug) << file << "is missing fingerprint.";
}
// The song's changed or missing fingerprint - create fingerprint and reread the metadata from file.
if (t->ignores_mtime() || changed || missing_fingerprint) {
QString fingerprint;
#ifdef HAVE_SONGFINGERPRINTING
if (song_tracking_) {
Chromaprinter chromaprinter(file);
fingerprint = chromaprinter.CreateFingerprint();
if (fingerprint.isEmpty()) {
fingerprint = "NONE";
}
}
#endif
if (!cue_deleted && (matching_song.has_cue() || cue_added)) { // If CUE associated. if (!cue_deleted && (matching_song.has_cue() || cue_added)) { // If CUE associated.
UpdateCueAssociatedSongs(file, path, matching_cue, image, t); UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, image, matching_songs, t);
} }
else { // If no CUE or it's about to lose it. else { // If no CUE or it's about to lose it.
UpdateNonCueAssociatedSong(file, matching_song, image, cue_deleted, t); UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, image, cue_deleted, t);
} }
} }
// Nothing has changed - mark the song available without re-scanning // Nothing has changed - mark the song available without re-scanning
if (matching_song.is_unavailable()) { else if (matching_song.is_unavailable()) {
if (matching_song.has_cue()) { t->readded_songs << matching_songs;
t->readded_songs << backend_->GetSongsByUrl(QUrl::fromLocalFile(file), true);
}
else {
t->readded_songs << matching_song;
}
} }
} }
else { else { // Search the DB by fingerprint.
// The song is on disk but not in the DB QString fingerprint;
SongList song_list = ScanNewFile(file, path, matching_cue, &cues_processed); #ifdef HAVE_SONGFINGERPRINTING
if (song_tracking_) {
if (song_list.isEmpty()) { Chromaprinter chromaprinter(file);
continue; fingerprint = chromaprinter.CreateFingerprint();
if (fingerprint.isEmpty()) {
fingerprint = "NONE";
}
} }
#endif
if (song_tracking_ && !fingerprint.isEmpty() && fingerprint != "NONE" && FindSongsByFingerprint(file, fingerprint, &matching_songs)) {
qLog(Debug) << file << "created"; // The song is in the database and still on disk.
// Choose an image for the song(s) // Check the mtime to see if it's been changed since it was added.
QUrl image = ImageForSong(file, album_art); QFileInfo file_info(file);
if (!file_info.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now.
files_on_disk.removeAll(file);
t->AddToProgress(1);
continue;
}
for (Song song : song_list) { // Make sure the songs aren't deleted, as they still exist elsewhere with a different fingerprint.
song.set_directory_id(t->dir()); bool matching_songs_has_cue = false;
if (song.art_automatic().isEmpty()) song.set_art_automatic(image); for (const Song &matching_song : matching_songs) {
t->new_songs << song; QString matching_filename = matching_song.url().toLocalFile();
if (!t->files_changed_path_.contains(matching_filename)) {
t->files_changed_path_ << matching_filename;
qLog(Debug) << matching_filename << "has changed path to" << file;
}
if (t->deleted_songs.contains(matching_song)) {
t->deleted_songs.removeAll(matching_song);
}
if (matching_song.has_cue()) {
matching_songs_has_cue = true;
}
}
// CUE sheet's path from this file (if any).
const qint64 new_cue_mtime = GetMtimeForCue(new_cue);
const bool cue_deleted = new_cue_mtime == 0 && matching_songs_has_cue;
const bool cue_added = new_cue_mtime != 0 && !matching_songs_has_cue;
// Get new album art
QUrl image = ImageForSong(file, album_art);
if (!cue_deleted && (matching_songs_has_cue || cue_added)) { // CUE associated.
UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, image, matching_songs, t);
}
else { // If no CUE or it's about to lose it.
UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, image, cue_deleted, t);
}
}
else { // The song is on disk but not in the DB
SongList songs = ScanNewFile(file, path, fingerprint, new_cue, &cues_processed);
if (songs.isEmpty()) {
t->AddToProgress(1);
continue;
}
qLog(Debug) << file << "is new.";
// Choose an image for the song(s)
QUrl image = ImageForSong(file, album_art);
for (Song song : songs) {
song.set_directory_id(t->dir());
if (song.art_automatic().isEmpty()) song.set_art_automatic(image);
t->new_songs << song;
}
} }
} }
t->AddToProgress(1);
} }
// Look for deleted songs // Look for deleted songs
for (const Song &song : songs_in_db) { for (const Song &song : songs_in_db) {
if (!song.is_unavailable() && !files_on_disk.contains(song.url().toLocalFile())) { QString file = song.url().toLocalFile();
qLog(Debug) << "Song deleted from disk:" << song.url().toLocalFile(); if (!song.is_unavailable() && !files_on_disk.contains(file) && !t->files_changed_path_.contains(file)) {
qLog(Debug) << "Song deleted from disk:" << file;
t->deleted_songs << song; t->deleted_songs << song;
} }
} }
@ -515,162 +673,178 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const Subdirectory
updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0; updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0;
updated_subdir.path = path; updated_subdir.path = path;
if (subdir.directory_id == -1) if (subdir.directory_id == -1) {
t->new_subdirs << updated_subdir; t->new_subdirs << updated_subdir;
else }
else {
t->touched_subdirs << updated_subdir; t->touched_subdirs << updated_subdir;
}
if (updated_subdir.mtime == 0) { // Subdirectory deleted, mark it for removal from the watcher. if (updated_subdir.mtime == 0) { // Subdirectory deleted, mark it for removal from the watcher.
t->deleted_subdirs << updated_subdir; t->deleted_subdirs << updated_subdir;
} }
t->AddToProgress(1);
// Recurse into the new subdirs that we found // Recurse into the new subdirs that we found
t->AddToProgressMax(my_new_subdirs.count());
for (const Subdirectory &my_new_subdir : my_new_subdirs) { for (const Subdirectory &my_new_subdir : my_new_subdirs) {
if (stop_requested_) return; if (stop_requested_) return;
ScanSubdirectory(my_new_subdir.path, my_new_subdir, t, true); ScanSubdirectory(my_new_subdir.path, my_new_subdir, 0, t, true);
} }
} }
void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file, const QString &path, const QString &matching_cue, const QUrl &image, ScanTransaction *t) { void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
const QString &path,
QFile cue(matching_cue); const QString &fingerprint,
cue.open(QIODevice::ReadOnly); const QString &matching_cue,
const QUrl &image,
SongList old_sections = backend_->GetSongsByUrl(QUrl::fromLocalFile(file)); const SongList &old_cue_songs,
ScanTransaction *t) {
QHash<quint64, Song> sections_map; QHash<quint64, Song> sections_map;
for (const Song &song : old_sections) { for (const Song &song : old_cue_songs) {
sections_map[song.beginning_nanosec()] = song; sections_map.insert(song.beginning_nanosec(), song);
} }
// Load new CUE songs
QFile cue_file(matching_cue);
if (!cue_file.exists() || !cue_file.open(QIODevice::ReadOnly)) return;
const SongList songs = cue_parser_->Load(&cue_file, matching_cue, path, false);
cue_file.close();
// Update every song that's in the CUE and collection
QSet<int> used_ids; QSet<int> used_ids;
for (Song new_cue_song : songs) {
new_cue_song.set_source(source_);
new_cue_song.set_directory_id(t->dir());
new_cue_song.set_fingerprint(fingerprint);
// Update every song that's in the cue and collection if (sections_map.contains(new_cue_song.beginning_nanosec())) { // Changed section
for (Song cue_song : cue_parser_->Load(&cue, matching_cue, path)) { const Song matching_cue_song = sections_map[new_cue_song.beginning_nanosec()];
cue_song.set_source(source_); new_cue_song.set_id(matching_cue_song.id());
cue_song.set_directory_id(t->dir()); if (!new_cue_song.has_embedded_cover()) new_cue_song.set_art_automatic(image);
new_cue_song.MergeUserSetData(matching_cue_song);
Song matching = sections_map[cue_song.beginning_nanosec()]; AddChangedSong(file, matching_cue_song, new_cue_song, t);
// A new section used_ids.insert(matching_cue_song.id());
if (!matching.is_valid()) {
t->new_songs << cue_song;
// changed section
} }
else { else { // A new section
PreserveUserSetData(file, image, matching, &cue_song, t); t->new_songs << new_cue_song;
used_ids.insert(matching.id());
} }
} }
// Sections that are now missing // Sections that are now missing
for (const Song &matching : old_sections) { for (const Song &old_cue : old_cue_songs) {
if (!used_ids.contains(matching.id())) { if (!used_ids.contains(old_cue.id())) {
t->deleted_songs << matching; t->deleted_songs << old_cue;
} }
} }
} }
void CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file, const Song &matching_song, const QUrl &image, bool cue_deleted, ScanTransaction *t) { void CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
const QString &fingerprint,
const SongList &matching_songs,
const QUrl &image,
const bool cue_deleted,
ScanTransaction *t) {
// If a CUE got deleted, we turn it's first section into the new 'raw' (cueless) song and we just remove the rest of the sections from the collection // If a CUE got deleted, we turn it's first section into the new 'raw' (cueless) song and we just remove the rest of the sections from the collection
const Song &matching_song = matching_songs.first();
if (cue_deleted) { if (cue_deleted) {
for (const Song &song : backend_->GetSongsByUrl(QUrl::fromLocalFile(file))) { for (const Song &song : matching_songs) {
if (!song.IsMetadataAndArtEqual(matching_song)) { if (!song.IsMetadataAndMoreEqual(matching_song)) {
t->deleted_songs << song; t->deleted_songs << song;
} }
} }
} }
Song song_on_disk(source_); Song song_on_disk(source_);
song_on_disk.set_directory_id(t->dir());
TagReaderClient::Instance()->ReadFileBlocking(file, &song_on_disk); TagReaderClient::Instance()->ReadFileBlocking(file, &song_on_disk);
if (song_on_disk.is_valid()) { if (song_on_disk.is_valid()) {
PreserveUserSetData(file, image, matching_song, &song_on_disk, t); song_on_disk.set_source(source_);
song_on_disk.set_directory_id(t->dir());
song_on_disk.set_id(matching_song.id());
song_on_disk.set_fingerprint(fingerprint);
if (!song_on_disk.has_embedded_cover()) song_on_disk.set_art_automatic(image);
song_on_disk.MergeUserSetData(matching_song);
AddChangedSong(file, matching_song, song_on_disk, t);
} }
} }
SongList CollectionWatcher::ScanNewFile(const QString &file, const QString &path, const QString &matching_cue, QSet<QString> *cues_processed) { SongList CollectionWatcher::ScanNewFile(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, QSet<QString> *cues_processed) {
SongList song_list; SongList songs;
quint64 matching_cue_mtime = GetMtimeForCue(matching_cue); quint64 matching_cue_mtime = GetMtimeForCue(matching_cue);
if (matching_cue_mtime) { // If it's a CUE - create virtual tracks if (matching_cue_mtime) { // If it's a CUE - create virtual tracks
// Don't process the same cue many times
if (cues_processed->contains(matching_cue)) return song_list;
QFile cue(matching_cue); // Don't process the same CUE many times
cue.open(QIODevice::ReadOnly); if (cues_processed->contains(matching_cue)) return songs;
QFile cue_file(matching_cue);
if (!cue_file.exists() || !cue_file.open(QIODevice::ReadOnly)) return songs;
// Ignore FILEs pointing to other media files. // Ignore FILEs pointing to other media files.
// Also, watch out for incorrect media files. // Also, watch out for incorrect media files.
// Playlist parser for CUEs considers every entry in sheet valid and we don't want invalid media getting into collection! // Playlist parser for CUEs considers every entry in sheet valid and we don't want invalid media getting into collection!
QString file_nfd = file.normalized(QString::NormalizationForm_D); QString file_nfd = file.normalized(QString::NormalizationForm_D);
for (Song &cue_song : cue_parser_->Load(&cue, matching_cue, path)) { SongList cue_congs = cue_parser_->Load(&cue_file, matching_cue, path, false);
for (Song &cue_song : cue_congs) {
cue_song.set_source(source_); cue_song.set_source(source_);
cue_song.set_fingerprint(fingerprint);
if (cue_song.url().toLocalFile().normalized(QString::NormalizationForm_D) == file_nfd) { if (cue_song.url().toLocalFile().normalized(QString::NormalizationForm_D) == file_nfd) {
if (TagReaderClient::Instance()->IsMediaFileBlocking(file)) { songs << cue_song;
song_list << cue_song;
}
} }
} }
if (!songs.isEmpty()) {
if (!song_list.isEmpty()) {
*cues_processed << matching_cue; *cues_processed << matching_cue;
} }
} }
else { // It's a normal media file else { // It's a normal media file
Song song(source_); Song song(source_);
TagReaderClient::Instance()->ReadFileBlocking(file, &song); TagReaderClient::Instance()->ReadFileBlocking(file, &song);
if (song.is_valid()) { if (song.is_valid()) {
song.set_source(source_); song.set_source(source_);
song_list << song; song.set_fingerprint(fingerprint);
songs << song;
} }
} }
return song_list; return songs;
} }
void CollectionWatcher::PreserveUserSetData(const QString &file, const QUrl &image, const Song &matching_song, Song *out, ScanTransaction *t) { void CollectionWatcher::AddChangedSong(const QString &file, const Song &matching_song, const Song &new_song, ScanTransaction *t) {
out->set_id(matching_song.id());
// Previous versions of Clementine incorrectly overwrote this and stored it in the DB,
// so we can't rely on matching_song to know if it has embedded artwork or not, but we can check here.
if (!out->has_embedded_cover()) out->set_art_automatic(image);
out->MergeUserSetData(matching_song);
// The song was deleted from the database (e.g. due to an unmounted filesystem), but has been restored.
if (matching_song.is_unavailable()) { if (matching_song.is_unavailable()) {
qLog(Debug) << file << " unavailable song restored"; qLog(Debug) << file << "unavailable song restored.";
t->new_songs << new_song;
t->new_songs << *out;
} }
else if (!matching_song.IsMetadataAndArtEqual(*out)) { else if (!matching_song.IsMetadataEqual(new_song)) {
qLog(Debug) << file << "metadata changed"; qLog(Debug) << file << "metadata changed.";
t->new_songs << new_song;
// Update the song in the DB }
t->new_songs << *out; else if (matching_song.fingerprint() != new_song.fingerprint()) {
qLog(Debug) << file << "fingerprint changed.";
t->new_songs << new_song;
}
else if (matching_song.art_automatic() != new_song.art_automatic() || matching_song.art_manual() != new_song.art_manual()) {
qLog(Debug) << file << "art changed.";
t->new_songs << new_song;
}
else if (matching_song.mtime() != new_song.mtime()) {
qLog(Debug) << file << "mtime changed.";
t->touched_songs << new_song;
} }
else { else {
// Only the mtime's changed qLog(Debug) << file << "unchanged.";
t->touched_songs << *out; t->touched_songs << new_song;
} }
} }
quint64 CollectionWatcher::GetMtimeForCue(const QString &cue_path) { quint64 CollectionWatcher::GetMtimeForCue(const QString &cue_path) {
// Slight optimisation
if (cue_path.isEmpty()) { if (cue_path.isEmpty()) {
return 0; return 0;
} }
@ -721,16 +895,46 @@ void CollectionWatcher::RemoveDirectory(const Directory &dir) {
} }
bool CollectionWatcher::FindSongByPath(const SongList &list, const QString &path, Song *out) { bool CollectionWatcher::FindSongsByPath(const SongList &songs, const QString &path, SongList *out) {
// TODO: Make this faster for (const Song &song : songs) {
for (const Song &song : list) {
if (song.url().toLocalFile() == path) { if (song.url().toLocalFile() == path) {
*out = song; *out << song;
}
}
return !out->isEmpty();
}
bool CollectionWatcher::FindSongsByFingerprint(const QString &file, const QString &fingerprint, SongList *out) {
SongList songs = backend_->GetSongsByFingerprint(fingerprint);
for (const Song &song : songs) {
QString filename = song.url().toLocalFile();
QFileInfo info(filename);
// Allow mulitiple songs in different directories with the same fingerprint.
// Only use the matching song by fingerprint if it doesn't already exist in a different path.
if (file == filename || !info.exists()) {
*out << song;
}
}
return !out->isEmpty();
}
bool CollectionWatcher::FindSongsByFingerprint(const QString &file, const SongList &songs, const QString &fingerprint, SongList *out) {
for (const Song &song : songs) {
QString filename = song.url().toLocalFile();
if (song.fingerprint() == fingerprint && (file == filename || !QFileInfo(filename).exists())) {
*out << song;
return true; return true;
} }
} }
return false;
return !out->isEmpty();
} }
@ -758,7 +962,13 @@ void CollectionWatcher::RescanPathsNow() {
for (const int dir : dirs) { for (const int dir : dirs) {
if (stop_requested_) break; if (stop_requested_) break;
ScanTransaction transaction(this, dir, false, false, mark_songs_unavailable_); ScanTransaction transaction(this, dir, false, false, mark_songs_unavailable_);
transaction.AddToProgressMax(rescan_queue_[dir].count());
QMap<QString, quint64> subdir_files_count;
for (const QString &path : rescan_queue_[dir]) {
quint64 files_count = FilesCountForPath(&transaction, path);
subdir_files_count[path] = files_count;
transaction.AddToProgressMax(files_count);
}
for (const QString &path : rescan_queue_[dir]) { for (const QString &path : rescan_queue_[dir]) {
if (stop_requested_) break; if (stop_requested_) break;
@ -766,7 +976,7 @@ void CollectionWatcher::RescanPathsNow() {
subdir.directory_id = dir; subdir.directory_id = dir;
subdir.mtime = 0; subdir.mtime = 0;
subdir.path = path; subdir.path = path;
ScanSubdirectory(path, subdir, &transaction); ScanSubdirectory(path, subdir, subdir_files_count[path], &transaction);
} }
} }
@ -877,6 +1087,16 @@ void CollectionWatcher::RescanTracksAsync(const SongList &songs) {
} }
void CollectionWatcher::IncrementalScanCheck() {
qint64 duration = QDateTime::currentDateTime().toSecsSinceEpoch() - last_scan_time_;
if (duration >= 86400) {
qLog(Debug) << "Performing periodic incremental scan.";
IncrementalScanNow();
}
}
void CollectionWatcher::IncrementalScanNow() { PerformScan(true, false); } void CollectionWatcher::IncrementalScanNow() { PerformScan(true, false); }
void CollectionWatcher::FullScanNow() { PerformScan(false, true); } void CollectionWatcher::FullScanNow() { PerformScan(false, true); }
@ -895,7 +1115,8 @@ void CollectionWatcher::RescanTracksNow() {
if (!scanned_dirs.contains(songdir)) { if (!scanned_dirs.contains(songdir)) {
qLog(Debug) << "Song" << song.title() << "dir id" << song.directory_id() << "dir" << songdir; qLog(Debug) << "Song" << song.title() << "dir id" << song.directory_id() << "dir" << songdir;
ScanTransaction transaction(this, song.directory_id(), false, false, mark_songs_unavailable_); ScanTransaction transaction(this, song.directory_id(), false, false, mark_songs_unavailable_);
ScanSubdirectory(songdir, Subdirectory(), &transaction); quint64 files_count = FilesCountForPath(&transaction, songdir);
ScanSubdirectory(songdir, Subdirectory(), files_count, &transaction);
scanned_dirs << songdir; scanned_dirs << songdir;
emit CompilationsNeedUpdating(); emit CompilationsNeedUpdating();
} }
@ -908,7 +1129,7 @@ void CollectionWatcher::RescanTracksNow() {
} }
void CollectionWatcher::PerformScan(bool incremental, bool ignore_mtimes) { void CollectionWatcher::PerformScan(const bool incremental, const bool ignore_mtimes) {
stop_requested_ = false; stop_requested_ = false;
@ -928,15 +1149,72 @@ void CollectionWatcher::PerformScan(bool incremental, bool ignore_mtimes) {
subdirs << subdir; subdirs << subdir;
} }
transaction.AddToProgressMax(subdirs.count()); QMap<QString, quint64> subdir_files_count;
quint64 files_count = FilesCountForSubdirs(&transaction, subdirs, subdir_files_count);
transaction.AddToProgressMax(files_count);
for (const Subdirectory &subdir : subdirs) { for (const Subdirectory &subdir : subdirs) {
if (stop_requested_) break; if (stop_requested_) break;
ScanSubdirectory(subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
ScanSubdirectory(subdir.path, subdir, &transaction);
} }
} }
last_scan_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
emit CompilationsNeedUpdating(); emit CompilationsNeedUpdating();
} }
quint64 CollectionWatcher::FilesCountForPath(ScanTransaction *t, const QString &path) {
quint64 i = 0;
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot);
while (it.hasNext()) {
if (stop_requested_) break;
QString child = it.next();
QFileInfo path_info(child);
if (path_info.isDir()) {
if (path_info.exists(kNoMediaFile) || path_info.exists(kNoMusicFile)) {
continue;
}
if (path_info.isSymLink()) {
QString real_path = path_info.symLinkTarget();
for (const Directory &dir : qAsConst(watched_dirs_)) {
if (real_path.startsWith(dir.path)) {
continue;
}
}
}
if (!t->HasSeenSubdir(child) && !path_info.isHidden()) {
// We haven't seen this subdirectory before, so we need to include the file count for this directory too.
i += FilesCountForPath(t, child);
}
}
++i;
}
return i;
}
quint64 CollectionWatcher::FilesCountForSubdirs(ScanTransaction *t, const SubdirectoryList &subdirs, QMap<QString, quint64> &subdir_files_count) {
quint64 i = 0;
for (const Subdirectory &subdir : subdirs) {
if (stop_requested_) break;
const quint64 files_count = FilesCountForPath(t, subdir.path);
subdir_files_count[subdir.path] = files_count;
i += files_count;
}
return i;
}

View File

@ -28,6 +28,7 @@
#include <QObject> #include <QObject>
#include <QHash> #include <QHash>
#include <QMap> #include <QMap>
#include <QMultiMap>
#include <QSet> #include <QSet>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
@ -52,12 +53,12 @@ class CollectionWatcher : public QObject {
void set_backend(CollectionBackend *backend) { backend_ = backend; } void set_backend(CollectionBackend *backend) { backend_ = backend; }
void set_task_manager(TaskManager *task_manager) { task_manager_ = task_manager; } void set_task_manager(TaskManager *task_manager) { task_manager_ = task_manager; }
void set_device_name(const QString& device_name) { device_name_ = device_name; } void set_device_name(const QString &device_name) { device_name_ = device_name; }
void IncrementalScanAsync(); void IncrementalScanAsync();
void FullScanAsync(); void FullScanAsync();
void RescanTracksAsync(const SongList &songs); void RescanTracksAsync(const SongList &songs);
void SetRescanPausedAsync(bool pause); void SetRescanPausedAsync(const bool pause);
void ReloadSettingsAsync(); void ReloadSettingsAsync();
void Stop() { stop_requested_ = true; } void Stop() { stop_requested_ = true; }
@ -73,12 +74,12 @@ class CollectionWatcher : public QObject {
void SubdirsDiscovered(SubdirectoryList subdirs); void SubdirsDiscovered(SubdirectoryList subdirs);
void SubdirsMTimeUpdated(SubdirectoryList subdirs); void SubdirsMTimeUpdated(SubdirectoryList subdirs);
void CompilationsNeedUpdating(); void CompilationsNeedUpdating();
void UpdateLastSeen(int directory_id, int expire_unavailable_songs_days);
void ExitFinished(); void ExitFinished();
void ScanStarted(int task_id); void ScanStarted(int task_id);
public slots: public slots:
void ReloadSettings();
void AddDirectory(const Directory &dir, const SubdirectoryList &subdirs); void AddDirectory(const Directory &dir, const SubdirectoryList &subdirs);
void RemoveDirectory(const Directory &dir); void RemoveDirectory(const Directory &dir);
void SetRescanPaused(bool pause); void SetRescanPaused(bool pause);
@ -96,13 +97,14 @@ class CollectionWatcher : public QObject {
~ScanTransaction(); ~ScanTransaction();
SongList FindSongsInSubdirectory(const QString &path); SongList FindSongsInSubdirectory(const QString &path);
bool HasSongsWithMissingFingerprint(const QString &path);
bool HasSeenSubdir(const QString &path); bool HasSeenSubdir(const QString &path);
void SetKnownSubdirs(const SubdirectoryList &subdirs); void SetKnownSubdirs(const SubdirectoryList &subdirs);
SubdirectoryList GetImmediateSubdirs(const QString &path); SubdirectoryList GetImmediateSubdirs(const QString &path);
SubdirectoryList GetAllSubdirs(); SubdirectoryList GetAllSubdirs();
void AddToProgress(int n = 1); void AddToProgress(const quint64 n = 1);
void AddToProgressMax(int n); void AddToProgressMax(const quint64 n);
// Emits the signals for new & deleted songs etc and clears the lists. This causes the new stuff to be updated on UI. // Emits the signals for new & deleted songs etc and clears the lists. This causes the new stuff to be updated on UI.
void CommitNewOrUpdatedSongs(); void CommitNewOrUpdatedSongs();
@ -119,13 +121,15 @@ class CollectionWatcher : public QObject {
SubdirectoryList touched_subdirs; SubdirectoryList touched_subdirs;
SubdirectoryList deleted_subdirs; SubdirectoryList deleted_subdirs;
QStringList files_changed_path_;
private: private:
ScanTransaction(const ScanTransaction&) {} ScanTransaction(const ScanTransaction&) {}
ScanTransaction& operator=(const ScanTransaction&) { return *this; } ScanTransaction &operator=(const ScanTransaction&) { return *this; }
int task_id_; int task_id_;
int progress_; quint64 progress_;
int progress_max_; quint64 progress_max_;
int dir_; int dir_;
// Incremental scan enters a directory only if it has changed since the last scan. // Incremental scan enters a directory only if it has changed since the last scan.
@ -138,27 +142,35 @@ class CollectionWatcher : public QObject {
// Set this to true to prevent deleting missing files from database. // Set this to true to prevent deleting missing files from database.
// Useful for unstable network connections. // Useful for unstable network connections.
bool mark_songs_unavailable_; bool mark_songs_unavailable_;
int expire_unavailable_songs_days_;
CollectionWatcher *watcher_; CollectionWatcher *watcher_;
SongList cached_songs_; QMultiMap<QString, Song> cached_songs_;
bool cached_songs_dirty_; bool cached_songs_dirty_;
QMultiMap<QString, Song> cached_songs_missing_fingerprint_;
bool cached_songs_missing_fingerprint_dirty_;
SubdirectoryList known_subdirs_; SubdirectoryList known_subdirs_;
bool known_subdirs_dirty_; bool known_subdirs_dirty_;
}; };
private slots: private slots:
void ReloadSettings();
void Exit(); void Exit();
void DirectoryChanged(const QString &subdir); void DirectoryChanged(const QString &subdir);
void IncrementalScanCheck();
void IncrementalScanNow(); void IncrementalScanNow();
void FullScanNow(); void FullScanNow();
void RescanTracksNow(); void RescanTracksNow();
void RescanPathsNow(); void RescanPathsNow();
void ScanSubdirectory(const QString &path, const Subdirectory &subdir, CollectionWatcher::ScanTransaction *t, bool force_noincremental = false); void ScanSubdirectory(const QString &path, const Subdirectory &subdir, const quint64 files_count, CollectionWatcher::ScanTransaction *t, const bool force_noincremental = false);
private: private:
static bool FindSongByPath(const SongList &list, const QString &path, Song *out); static bool FindSongsByPath(const SongList &list, const QString &path, SongList *out);
bool FindSongsByFingerprint(const QString &file, const QString &fingerprint, SongList *out);
static bool FindSongsByFingerprint(const QString &file, const SongList &songs, const QString &fingerprint, SongList *out);
inline static QString NoExtensionPart(const QString &fileName); inline static QString NoExtensionPart(const QString &fileName);
inline static QString ExtensionPart(const QString &fileName); inline static QString ExtensionPart(const QString &fileName);
inline static QString DirectoryPart(const QString &fileName); inline static QString DirectoryPart(const QString &fileName);
@ -167,17 +179,20 @@ class CollectionWatcher : public QObject {
void AddWatch(const Directory &dir, const QString &path); void AddWatch(const Directory &dir, const QString &path);
void RemoveWatch(const Directory &dir, const Subdirectory &subdir); void RemoveWatch(const Directory &dir, const Subdirectory &subdir);
quint64 GetMtimeForCue(const QString &cue_path); quint64 GetMtimeForCue(const QString &cue_path);
void PerformScan(bool incremental, bool ignore_mtimes); void PerformScan(const bool incremental, const bool ignore_mtimes);
// Updates the sections of a cue associated and altered (according to mtime) media file during a scan. // Updates the sections of a cue associated and altered (according to mtime) media file during a scan.
void UpdateCueAssociatedSongs(const QString &file, const QString &path, const QString &matching_cue, const QUrl &image, ScanTransaction *t); void UpdateCueAssociatedSongs(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, const QUrl &image, const SongList &old_cue_songs, ScanTransaction *t);
// Updates a single non-cue associated and altered (according to mtime) song during a scan. // Updates a single non-cue associated and altered (according to mtime) song during a scan.
void UpdateNonCueAssociatedSong(const QString &file, const Song &matching_song, const QUrl &image, bool cue_deleted, ScanTransaction *t); void UpdateNonCueAssociatedSong(const QString &file, const QString &fingerprint, const SongList &matching_songs, const QUrl &image, const bool cue_deleted, ScanTransaction *t);
// Updates a new song with some metadata taken from it's equivalent old song (for example rating and score).
void PreserveUserSetData(const QString &file, const QUrl &image, const Song &matching_song, Song *out, ScanTransaction *t);
// Scans a single media file that's present on the disk but not yet in the collection. // Scans a single media file that's present on the disk but not yet in the collection.
// It may result in a multiple files added to the collection when the media file has many sections (like a CUE related media file). // It may result in a multiple files added to the collection when the media file has many sections (like a CUE related media file).
SongList ScanNewFile(const QString &file, const QString &path, const QString &matching_cue, QSet<QString> *cues_processed); SongList ScanNewFile(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, QSet<QString> *cues_processed);
void AddChangedSong(const QString &file, const Song &matching_song, const Song &new_song, ScanTransaction *t);
quint64 FilesCountForPath(ScanTransaction *t, const QString &path);
quint64 FilesCountForSubdirs(ScanTransaction *t, const SubdirectoryList &subdirs, QMap<QString, quint64> &subdir_files_count);
private: private:
Song::Source source_; Song::Source source_;
@ -186,6 +201,7 @@ class CollectionWatcher : public QObject {
QString device_name_; QString device_name_;
FileSystemWatcherInterface *fs_watcher_; FileSystemWatcherInterface *fs_watcher_;
QThread *original_thread_;
QHash<QString, Directory> subdir_mapping_; QHash<QString, Directory> subdir_mapping_;
// A list of words use to try to identify the (likely) best image found in an directory to use as cover artwork. // A list of words use to try to identify the (likely) best image found in an directory to use as cover artwork.
@ -194,13 +210,16 @@ class CollectionWatcher : public QObject {
bool scan_on_startup_; bool scan_on_startup_;
bool monitor_; bool monitor_;
bool song_tracking_;
bool mark_songs_unavailable_; bool mark_songs_unavailable_;
int expire_unavailable_songs_days_;
bool stop_requested_; bool stop_requested_;
bool rescan_in_progress_; // True if RescanTracksNow() has been called and is working. bool rescan_in_progress_; // True if RescanTracksNow() has been called and is working.
QMap<int, Directory> watched_dirs_; QMap<int, Directory> watched_dirs_;
QTimer *rescan_timer_; QTimer *rescan_timer_;
QTimer *periodic_scan_timer_;
QMap<int, QStringList> rescan_queue_; // dir id -> list of subdirs to be scanned QMap<int, QStringList> rescan_queue_; // dir id -> list of subdirs to be scanned
bool rescan_paused_; bool rescan_paused_;
@ -212,7 +231,7 @@ class CollectionWatcher : public QObject {
SongList song_rescan_queue_; // Set by ui thread SongList song_rescan_queue_; // Set by ui thread
QThread *original_thread_; qint64 last_scan_time_;
}; };

View File

@ -17,6 +17,7 @@
#cmakedefine HAVE_LIBPULSE #cmakedefine HAVE_LIBPULSE
#cmakedefine HAVE_SPARKLE #cmakedefine HAVE_SPARKLE
#cmakedefine HAVE_QTSPARKLE #cmakedefine HAVE_QTSPARKLE
#cmakedefine HAVE_SONGFINGERPRINTING
#cmakedefine HAVE_MUSICBRAINZ #cmakedefine HAVE_MUSICBRAINZ
#cmakedefine HAVE_GLOBALSHORTCUTS #cmakedefine HAVE_GLOBALSHORTCUTS
#cmakedefine HAVE_X11_GLOBALSHORTCUTS #cmakedefine HAVE_X11_GLOBALSHORTCUTS

View File

@ -54,7 +54,7 @@
#include "scopedtransaction.h" #include "scopedtransaction.h"
const char *Database::kDatabaseFilename = "strawberry.db"; const char *Database::kDatabaseFilename = "strawberry.db";
const int Database::kSchemaVersion = 13; const int Database::kSchemaVersion = 14;
const char *Database::kMagicAllSongsTables = "%allsongstables"; const char *Database::kMagicAllSongsTables = "%allsongstables";
int Database::sNextConnectionId = 1; int Database::sNextConnectionId = 1;

View File

@ -103,9 +103,12 @@ const QStringList Song::kColumns = QStringList() << "title"
<< "ctime" << "ctime"
<< "unavailable" << "unavailable"
<< "fingerprint"
<< "playcount" << "playcount"
<< "skipcount" << "skipcount"
<< "lastplayed" << "lastplayed"
<< "lastseen"
<< "compilation_detected" << "compilation_detected"
<< "compilation_on" << "compilation_on"
@ -202,9 +205,12 @@ struct Song::Private : public QSharedData {
qint64 ctime_; qint64 ctime_;
bool unavailable_; bool unavailable_;
QString fingerprint_;
int playcount_; int playcount_;
int skipcount_; int skipcount_;
qint64 lastplayed_; qint64 lastplayed_;
qint64 lastseen_;
bool compilation_detected_; // From the collection scanner bool compilation_detected_; // From the collection scanner
bool compilation_on_; // Set by the user bool compilation_on_; // Set by the user
@ -255,6 +261,7 @@ Song::Private::Private(Song::Source source)
playcount_(0), playcount_(0),
skipcount_(0), skipcount_(0),
lastplayed_(-1), lastplayed_(-1),
lastseen_(-1),
compilation_detected_(false), compilation_detected_(false),
compilation_on_(false), compilation_on_(false),
@ -328,9 +335,12 @@ int Song::filesize() const { return d->filesize_; }
qint64 Song::mtime() const { return d->mtime_; } qint64 Song::mtime() const { return d->mtime_; }
qint64 Song::ctime() const { return d->ctime_; } qint64 Song::ctime() const { return d->ctime_; }
QString Song::fingerprint() const { return d->fingerprint_; }
int Song::playcount() const { return d->playcount_; } int Song::playcount() const { return d->playcount_; }
int Song::skipcount() const { return d->skipcount_; } int Song::skipcount() const { return d->skipcount_; }
qint64 Song::lastplayed() const { return d->lastplayed_; } qint64 Song::lastplayed() const { return d->lastplayed_; }
qint64 Song::lastseen() const { return d->lastseen_; }
bool Song::compilation_detected() const { return d->compilation_detected_; } bool Song::compilation_detected() const { return d->compilation_detected_; }
bool Song::compilation_off() const { return d->compilation_off_; } bool Song::compilation_off() const { return d->compilation_off_; }
@ -451,9 +461,12 @@ void Song::set_mtime(qint64 v) { d->mtime_ = v; }
void Song::set_ctime(qint64 v) { d->ctime_ = v; } void Song::set_ctime(qint64 v) { d->ctime_ = v; }
void Song::set_unavailable(bool v) { d->unavailable_ = v; } void Song::set_unavailable(bool v) { d->unavailable_ = v; }
void Song::set_fingerprint(const QString &v) { d->fingerprint_ = v; }
void Song::set_playcount(int v) { d->playcount_ = v; } void Song::set_playcount(int v) { d->playcount_ = v; }
void Song::set_skipcount(int v) { d->skipcount_ = v; } void Song::set_skipcount(int v) { d->skipcount_ = v; }
void Song::set_lastplayed(qint64 v) { d->lastplayed_ = v; } void Song::set_lastplayed(qint64 v) { d->lastplayed_ = v; }
void Song::set_lastseen(qint64 v) { d->lastseen_ = v; }
void Song::set_compilation_detected(bool v) { d->compilation_detected_ = v; } void Song::set_compilation_detected(bool v) { d->compilation_detected_ = v; }
void Song::set_compilation_on(bool v) { d->compilation_on_ = v; } void Song::set_compilation_on(bool v) { d->compilation_on_ = v; }
@ -789,6 +802,7 @@ void Song::InitFromProtobuf(const spb::tagreader::SongMetadata &pb) {
d->ctime_ = pb.ctime(); d->ctime_ = pb.ctime();
d->skipcount_ = pb.skipcount(); d->skipcount_ = pb.skipcount();
d->lastplayed_ = pb.lastplayed(); d->lastplayed_ = pb.lastplayed();
d->lastseen_ = pb.lastseen();
d->suspicious_tags_ = pb.suspicious_tags(); d->suspicious_tags_ = pb.suspicious_tags();
if (pb.has_playcount()) { if (pb.has_playcount()) {
@ -828,6 +842,7 @@ void Song::ToProtobuf(spb::tagreader::SongMetadata *pb) const {
pb->set_playcount(d->playcount_); pb->set_playcount(d->playcount_);
pb->set_skipcount(d->skipcount_); pb->set_skipcount(d->skipcount_);
pb->set_lastplayed(d->lastplayed_); pb->set_lastplayed(d->lastplayed_);
pb->set_lastseen(d->lastseen_);
pb->set_length_nanosec(length_nanosec()); pb->set_length_nanosec(length_nanosec());
pb->set_bitrate(d->bitrate_); pb->set_bitrate(d->bitrate_);
pb->set_samplerate(d->samplerate_); pb->set_samplerate(d->samplerate_);
@ -964,6 +979,10 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) {
d->unavailable_ = q.value(x).toBool(); d->unavailable_ = q.value(x).toBool();
} }
else if (Song::kColumns.value(i) == "fingerprint") {
d->fingerprint_ = tostr(x);
}
else if (Song::kColumns.value(i) == "playcount") { else if (Song::kColumns.value(i) == "playcount") {
d->playcount_ = q.value(x).isNull() ? 0 : q.value(x).toInt(); d->playcount_ = q.value(x).isNull() ? 0 : q.value(x).toInt();
} }
@ -973,6 +992,9 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) {
else if (Song::kColumns.value(i) == "lastplayed") { else if (Song::kColumns.value(i) == "lastplayed") {
d->lastplayed_ = tolonglong(x); d->lastplayed_ = tolonglong(x);
} }
else if (Song::kColumns.value(i) == "lastseen") {
d->lastseen_ = tolonglong(x);
}
else if (Song::kColumns.value(i) == "compilation_detected") { else if (Song::kColumns.value(i) == "compilation_detected") {
d->compilation_detected_ = q.value(x).toBool(); d->compilation_detected_ = q.value(x).toBool();
@ -1376,9 +1398,12 @@ void Song::BindToQuery(QSqlQuery *query) const {
query->bindValue(":ctime", notnullintval(d->ctime_)); query->bindValue(":ctime", notnullintval(d->ctime_));
query->bindValue(":unavailable", d->unavailable_ ? 1 : 0); query->bindValue(":unavailable", d->unavailable_ ? 1 : 0);
query->bindValue(":fingerprint", strval(d->fingerprint_));
query->bindValue(":playcount", d->playcount_); query->bindValue(":playcount", d->playcount_);
query->bindValue(":skipcount", d->skipcount_); query->bindValue(":skipcount", d->skipcount_);
query->bindValue(":lastplayed", intval(d->lastplayed_)); query->bindValue(":lastplayed", intval(d->lastplayed_));
query->bindValue(":lastseen", intval(d->lastseen_));
query->bindValue(":compilation_detected", d->compilation_detected_ ? 1 : 0); query->bindValue(":compilation_detected", d->compilation_detected_ ? 1 : 0);
query->bindValue(":compilation_on", d->compilation_on_ ? 1 : 0); query->bindValue(":compilation_on", d->compilation_on_ ? 1 : 0);
@ -1519,9 +1544,12 @@ bool Song::IsMetadataEqual(const Song &other) const {
d->cue_path_ == other.d->cue_path_; d->cue_path_ == other.d->cue_path_;
} }
bool Song::IsMetadataAndArtEqual(const Song &other) const { bool Song::IsMetadataAndMoreEqual(const Song &other) const {
return IsMetadataEqual(other) && d->art_automatic_ == other.d->art_automatic_ && d->art_manual_ == other.d->art_manual_; return IsMetadataEqual(other) &&
d->fingerprint_ == other.d->fingerprint_ &&
d->art_automatic_ == other.d->art_automatic_ &&
d->art_manual_ == other.d->art_manual_;
} }

View File

@ -231,9 +231,12 @@ class Song {
qint64 mtime() const; qint64 mtime() const;
qint64 ctime() const; qint64 ctime() const;
QString fingerprint() const;
int playcount() const; int playcount() const;
int skipcount() const; int skipcount() const;
qint64 lastplayed() const; qint64 lastplayed() const;
qint64 lastseen() const;
bool compilation_detected() const; bool compilation_detected() const;
bool compilation_off() const; bool compilation_off() const;
@ -345,9 +348,12 @@ class Song {
void set_ctime(qint64 v); void set_ctime(qint64 v);
void set_unavailable(bool v); void set_unavailable(bool v);
void set_fingerprint(const QString &v);
void set_playcount(int v); void set_playcount(int v);
void set_skipcount(int v); void set_skipcount(int v);
void set_lastplayed(qint64 v); void set_lastplayed(qint64 v);
void set_lastseen(qint64 v);
void set_compilation_detected(bool v); void set_compilation_detected(bool v);
void set_compilation_on(bool v); void set_compilation_on(bool v);
@ -365,7 +371,7 @@ class Song {
// Comparison functions // Comparison functions
bool IsMetadataEqual(const Song &other) const; bool IsMetadataEqual(const Song &other) const;
bool IsMetadataAndArtEqual(const Song &other) const; bool IsMetadataAndMoreEqual(const Song &other) const;
bool IsOnSameAlbum(const Song &other) const; bool IsOnSameAlbum(const Song &other) const;
bool IsSimilar(const Song &other) const; bool IsSimilar(const Song &other) const;

View File

@ -160,20 +160,13 @@ QString Chromaprinter::CreateFingerprint() {
chromaprint_feed(chromaprint, reinterpret_cast<int16_t*>(data.data()), static_cast<int>(data.size() / 2)); chromaprint_feed(chromaprint, reinterpret_cast<int16_t*>(data.data()), static_cast<int>(data.size() / 2));
chromaprint_finish(chromaprint); chromaprint_finish(chromaprint);
int size = 0;
#if CHROMAPRINT_VERSION_MAJOR >= 1 && CHROMAPRINT_VERSION_MINOR >= 4
u_int32_t *fprint = nullptr; u_int32_t *fprint = nullptr;
char *encoded = nullptr; int size = 0;
#else
void *fprint = nullptr;
void *encoded = nullptr;
#endif
int ret = chromaprint_get_raw_fingerprint(chromaprint, &fprint, &size); int ret = chromaprint_get_raw_fingerprint(chromaprint, &fprint, &size);
QByteArray fingerprint; QByteArray fingerprint;
if (ret == 1) { if (ret == 1) {
char *encoded = nullptr;
int encoded_size = 0; int encoded_size = 0;
chromaprint_encode_fingerprint(fprint, size, CHROMAPRINT_ALGORITHM_DEFAULT, &encoded, &encoded_size, 1); chromaprint_encode_fingerprint(fprint, size, CHROMAPRINT_ALGORITHM_DEFAULT, &encoded, &encoded_size, 1);

View File

@ -29,8 +29,8 @@
#include <QUrl> #include <QUrl>
#include "core/timeconstants.h" #include "core/timeconstants.h"
#include "engine/chromaprinter.h"
#include "acoustidclient.h" #include "acoustidclient.h"
#include "chromaprinter.h"
#include "musicbrainzclient.h" #include "musicbrainzclient.h"
#include "tagfetcher.h" #include "tagfetcher.h"

View File

@ -272,11 +272,11 @@ PlaylistItemPtr PlaylistBackend::RestoreCueData(PlaylistItemPtr item, std::share
CueParser cue_parser(app_->collection_backend()); CueParser cue_parser(app_->collection_backend());
Song song = item->Metadata(); Song song = item->Metadata();
// we're only interested in .cue songs here // We're only interested in .cue songs here
if (!song.has_cue()) return item; if (!song.has_cue()) return item;
QString cue_path = song.cue_path(); QString cue_path = song.cue_path();
// if .cue was deleted - reload the song // If .cue was deleted - reload the song
if (!QFile::exists(cue_path)) { if (!QFile::exists(cue_path)) {
item->Reload(); item->Reload();
return item; return item;
@ -287,10 +287,10 @@ PlaylistItemPtr PlaylistBackend::RestoreCueData(PlaylistItemPtr item, std::share
QMutexLocker locker(&state->mutex_); QMutexLocker locker(&state->mutex_);
if (!state->cached_cues_.contains(cue_path)) { if (!state->cached_cues_.contains(cue_path)) {
QFile cue(cue_path); QFile cue_file(cue_path);
cue.open(QIODevice::ReadOnly); if (!cue_file.open(QIODevice::ReadOnly)) return item;
song_list = cue_parser.Load(&cue, cue_path, QDir(cue_path.section('/', 0, -2))); song_list = cue_parser.Load(&cue_file, cue_path, QDir(cue_path.section('/', 0, -2)));
state->cached_cues_[cue_path] = song_list; state->cached_cues_[cue_path] = song_list;
} }
else { else {
@ -300,13 +300,14 @@ PlaylistItemPtr PlaylistBackend::RestoreCueData(PlaylistItemPtr item, std::share
for (const Song &from_list : song_list) { for (const Song &from_list : song_list) {
if (from_list.url().toEncoded() == song.url().toEncoded() && from_list.beginning_nanosec() == song.beginning_nanosec()) { if (from_list.url().toEncoded() == song.url().toEncoded() && from_list.beginning_nanosec() == song.beginning_nanosec()) {
// we found a matching section; replace the input item with a new one containing CUE metadata // We found a matching section; replace the input item with a new one containing CUE metadata
return PlaylistItemPtr(new SongPlaylistItem(from_list)); return PlaylistItemPtr(new SongPlaylistItem(from_list));
} }
} }
// there's no such section in the related .cue -> reload the song // There's no such section in the related .cue -> reload the song
item->Reload(); item->Reload();
return item; return item;
} }

View File

@ -44,7 +44,7 @@ bool AsxIniParser::TryMagic(const QByteArray &data) const {
return data.toLower().contains("[reference]"); return data.toLower().contains("[reference]");
} }
SongList AsxIniParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList AsxIniParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -57,7 +57,7 @@ SongList AsxIniParser::Load(QIODevice *device, const QString &playlist_path, con
QString value = line.mid(equals + 1); QString value = line.mid(equals + 1);
if (key.startsWith("ref")) { if (key.startsWith("ref")) {
Song song = LoadSong(value, 0, dir); Song song = LoadSong(value, 0, dir, collection_search);
if (song.is_valid()) { if (song.is_valid()) {
ret << song; ret << song;
} }

View File

@ -46,7 +46,7 @@ class AsxIniParser : public ParserBase {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
}; };

View File

@ -41,7 +41,7 @@ class CollectionBackendInterface;
ASXParser::ASXParser(CollectionBackendInterface *collection, QObject *parent) ASXParser::ASXParser(CollectionBackendInterface *collection, QObject *parent)
: XMLParser(collection, parent) {} : XMLParser(collection, parent) {}
SongList ASXParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList ASXParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -73,7 +73,7 @@ SongList ASXParser::Load(QIODevice *device, const QString &playlist_path, const
} }
while (!reader.atEnd() && Utilities::ParseUntilElementCI(&reader, "entry")) { while (!reader.atEnd() && Utilities::ParseUntilElementCI(&reader, "entry")) {
Song song = ParseTrack(&reader, dir); Song song = ParseTrack(&reader, dir, collection_search);
if (song.is_valid()) { if (song.is_valid()) {
ret << song; ret << song;
} }
@ -83,7 +83,7 @@ SongList ASXParser::Load(QIODevice *device, const QString &playlist_path, const
} }
Song ASXParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir) const { Song ASXParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir, const bool collection_search) const {
QString title, artist, album, ref; QString title, artist, album, ref;
@ -117,7 +117,7 @@ Song ASXParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir) const {
} }
return_song: return_song:
Song song = LoadSong(ref, 0, dir); Song song = LoadSong(ref, 0, dir, collection_search);
// Override metadata with what was in the playlist // Override metadata with what was in the playlist
if (song.source() != Song::Source_Collection) { if (song.source() != Song::Source_Collection) {

View File

@ -48,11 +48,11 @@ class ASXParser : public XMLParser {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
private: private:
Song ParseTrack(QXmlStreamReader *reader, const QDir &dir) const; Song ParseTrack(QXmlStreamReader *reader, const QDir &dir, const bool collection_search) const;
}; };
#endif #endif

View File

@ -58,7 +58,7 @@ const char *CueParser::kDisc = "discnumber";
CueParser::CueParser(CollectionBackendInterface *collection, QObject *parent) CueParser::CueParser(CollectionBackendInterface *collection, QObject *parent)
: ParserBase(collection, parent) {} : ParserBase(collection, parent) {}
SongList CueParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList CueParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
SongList ret; SongList ret;
@ -230,7 +230,7 @@ SongList CueParser::Load(QIODevice *device, const QString &playlist_path, const
for (int i = 0; i < entries.length(); i++) { for (int i = 0; i < entries.length(); i++) {
CueEntry entry = entries.at(i); CueEntry entry = entries.at(i);
Song song = LoadSong(entry.file, IndexToMarker(entry.index), dir); Song song = LoadSong(entry.file, IndexToMarker(entry.index), dir, collection_search);
// Cue song has mtime equal to qMax(media_file_mtime, cue_sheet_mtime) // Cue song has mtime equal to qMax(media_file_mtime, cue_sheet_mtime)
if (cue_mtime.isValid()) { if (cue_mtime.isValid()) {

View File

@ -67,7 +67,7 @@ class CueParser : public ParserBase {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
private: private:

View File

@ -42,7 +42,7 @@ class CollectionBackendInterface;
M3UParser::M3UParser(CollectionBackendInterface *collection, QObject *parent) M3UParser::M3UParser(CollectionBackendInterface *collection, QObject *parent)
: ParserBase(collection, parent) {} : ParserBase(collection, parent) {}
SongList M3UParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList M3UParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -75,7 +75,7 @@ SongList M3UParser::Load(QIODevice *device, const QString &playlist_path, const
} }
} }
else if (!line.isEmpty()) { else if (!line.isEmpty()) {
Song song = LoadSong(line, 0, dir); Song song = LoadSong(line, 0, dir, collection_search);
if (!current_metadata.title.isEmpty()) { if (!current_metadata.title.isEmpty()) {
song.set_title(current_metadata.title); song.set_title(current_metadata.title);
} }

View File

@ -49,7 +49,7 @@ class M3UParser : public ParserBase {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
private: private:

View File

@ -36,7 +36,7 @@
ParserBase::ParserBase(CollectionBackendInterface *collection, QObject *parent) ParserBase::ParserBase(CollectionBackendInterface *collection, QObject *parent)
: QObject(parent), collection_(collection) {} : QObject(parent), collection_(collection) {}
void ParserBase::LoadSong(const QString &filename_or_url, qint64 beginning, const QDir &dir, Song *song) const { void ParserBase::LoadSong(const QString &filename_or_url, const qint64 beginning, const QDir &dir, Song *song, const bool collection_search) const {
if (filename_or_url.isEmpty()) { if (filename_or_url.isEmpty()) {
return; return;
@ -78,25 +78,24 @@ void ParserBase::LoadSong(const QString &filename_or_url, qint64 beginning, cons
const QUrl url = QUrl::fromLocalFile(filename); const QUrl url = QUrl::fromLocalFile(filename);
// Search in the collection // Search in the collection
Song collection_song(Song::Source_Collection); if (collection_ && collection_search) {
if (collection_) { Song collection_song = collection_->GetSongByUrl(url, beginning);
collection_song = collection_->GetSongByUrl(url, beginning); // If it was found in the collection then use it, otherwise load metadata from disk.
if (collection_song.is_valid()) {
*song = collection_song;
return;
}
} }
// If it was found in the collection then use it, otherwise load metadata from disk. TagReaderClient::Instance()->ReadFileBlocking(filename, song);
if (collection_song.is_valid()) {
*song = collection_song;
}
else {
TagReaderClient::Instance()->ReadFileBlocking(filename, song);
}
} }
Song ParserBase::LoadSong(const QString &filename_or_url, qint64 beginning, const QDir &dir) const { Song ParserBase::LoadSong(const QString &filename_or_url, const qint64 beginning, const QDir &dir, const bool collection_search) const {
Song song(Song::Source_LocalFile); Song song(Song::Source_LocalFile);
LoadSong(filename_or_url, beginning, dir, &song); LoadSong(filename_or_url, beginning, dir, &song, collection_search);
return song; return song;
} }

View File

@ -54,7 +54,7 @@ class ParserBase : public QObject {
// This method might not return all of the songs found in the playlist. // This method might not return all of the songs found in the playlist.
// Any playlist parser may decide to leave out some entries if it finds them incomplete or invalid. // Any playlist parser may decide to leave out some entries if it finds them incomplete or invalid.
// This means that the final resulting SongList should be considered valid (at least from the parser's point of view). // This means that the final resulting SongList should be considered valid (at least from the parser's point of view).
virtual SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const = 0; virtual SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_lookup = true) const = 0;
virtual void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const = 0; virtual void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const = 0;
protected: protected:
@ -62,8 +62,8 @@ class ParserBase : public QObject {
// If it is a filename or a file:// URL then it is made absolute and canonical and set as a file:// url on the song. // If it is a filename or a file:// URL then it is made absolute and canonical and set as a file:// url on the song.
// Also sets the song's metadata by searching in the Collection, or loading from the file as a fallback. // Also sets the song's metadata by searching in the Collection, or loading from the file as a fallback.
// This function should always be used when loading a playlist. // This function should always be used when loading a playlist.
Song LoadSong(const QString &filename_or_url, qint64 beginning, const QDir &dir) const; Song LoadSong(const QString &filename_or_url, const qint64 beginning, const QDir &dir, const bool collection_search) const;
void LoadSong(const QString &filename_or_url, qint64 beginning, const QDir &dir, Song *song) const; void LoadSong(const QString &filename_or_url, const qint64 beginning, const QDir &dir, Song *song, const bool collection_search) const;
// If the URL is a file:// URL then returns its path, absolute or relative to the directory depending on the path_type option. // If the URL is a file:// URL then returns its path, absolute or relative to the directory depending on the path_type option.
// Otherwise returns the URL as is. This function should always be used when saving a playlist. // Otherwise returns the URL as is. This function should always be used when saving a playlist.

View File

@ -44,7 +44,7 @@ class CollectionBackendInterface;
PLSParser::PLSParser(CollectionBackendInterface *collection, QObject *parent) PLSParser::PLSParser(CollectionBackendInterface *collection, QObject *parent)
: ParserBase(collection, parent) {} : ParserBase(collection, parent) {}
SongList PLSParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList PLSParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -61,7 +61,7 @@ SongList PLSParser::Load(QIODevice *device, const QString &playlist_path, const
int n = re_match.captured(0).toInt(); int n = re_match.captured(0).toInt();
if (key.startsWith("file")) { if (key.startsWith("file")) {
Song song = LoadSong(value, 0, dir); Song song = LoadSong(value, 0, dir, collection_search);
// Use the title and length we've already loaded if any // Use the title and length we've already loaded if any
if (!songs[n].title().isEmpty()) song.set_title(songs[n].title()); if (!songs[n].title().isEmpty()) song.set_title(songs[n].title());

View File

@ -47,7 +47,7 @@ class PLSParser : public ParserBase {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
}; };

View File

@ -41,7 +41,7 @@ bool WplParser::TryMagic(const QByteArray &data) const {
return data.contains("<?wpl") || data.contains("<smil>"); return data.contains("<?wpl") || data.contains("<smil>");
} }
SongList WplParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList WplParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -53,13 +53,13 @@ SongList WplParser::Load(QIODevice *device, const QString &playlist_path, const
} }
while (!reader.atEnd() && Utilities::ParseUntilElement(&reader, "seq")) { while (!reader.atEnd() && Utilities::ParseUntilElement(&reader, "seq")) {
ParseSeq(dir, &reader, &ret); ParseSeq(dir, &reader, &ret, collection_search);
} }
return ret; return ret;
} }
void WplParser::ParseSeq(const QDir &dir, QXmlStreamReader *reader, SongList *songs) const { void WplParser::ParseSeq(const QDir &dir, QXmlStreamReader *reader, SongList *songs, const bool collection_search) const {
while (!reader->atEnd()) { while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext(); QXmlStreamReader::TokenType type = reader->readNext();
@ -69,7 +69,7 @@ void WplParser::ParseSeq(const QDir &dir, QXmlStreamReader *reader, SongList *so
if (name == "media") { if (name == "media") {
QString src = reader->attributes().value("src").toString(); QString src = reader->attributes().value("src").toString();
if (!src.isEmpty()) { if (!src.isEmpty()) {
Song song = LoadSong(src, 0, dir); Song song = LoadSong(src, 0, dir, collection_search);
if (song.is_valid()) { if (song.is_valid()) {
songs->append(song); songs->append(song);
} }

View File

@ -49,11 +49,11 @@ class WplParser : public XMLParser {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const override; SongList Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir, Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir, Playlist::Path path_type = Playlist::Path_Automatic) const override;
private: private:
void ParseSeq(const QDir &dir, QXmlStreamReader *reader, SongList *songs) const; void ParseSeq(const QDir &dir, QXmlStreamReader *reader, SongList *songs, const bool collection_search = true) const;
void WriteMeta(const QString &name, const QString &content, QXmlStreamWriter *writer) const; void WriteMeta(const QString &name, const QString &content, QXmlStreamWriter *writer) const;
}; };

View File

@ -42,7 +42,7 @@ class CollectionBackendInterface;
XSPFParser::XSPFParser(CollectionBackendInterface *collection, QObject *parent) XSPFParser::XSPFParser(CollectionBackendInterface *collection, QObject *parent)
: XMLParser(collection, parent) {} : XMLParser(collection, parent) {}
SongList XSPFParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir) const { SongList XSPFParser::Load(QIODevice *device, const QString &playlist_path, const QDir &dir, const bool collection_search) const {
Q_UNUSED(playlist_path); Q_UNUSED(playlist_path);
@ -54,7 +54,7 @@ SongList XSPFParser::Load(QIODevice *device, const QString &playlist_path, const
} }
while (!reader.atEnd() && Utilities::ParseUntilElement(&reader, "track")) { while (!reader.atEnd() && Utilities::ParseUntilElement(&reader, "track")) {
Song song = ParseTrack(&reader, dir); Song song = ParseTrack(&reader, dir, collection_search);
if (song.is_valid()) { if (song.is_valid()) {
ret << song; ret << song;
} }
@ -63,7 +63,7 @@ SongList XSPFParser::Load(QIODevice *device, const QString &playlist_path, const
} }
Song XSPFParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir) const { Song XSPFParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir, const bool collection_search) const {
QString title, artist, album, location; QString title, artist, album, location;
qint64 nanosec = -1; qint64 nanosec = -1;
@ -121,7 +121,7 @@ Song XSPFParser::ParseTrack(QXmlStreamReader *reader, const QDir &dir) const {
} }
return_song: return_song:
Song song = LoadSong(location, 0, dir); Song song = LoadSong(location, 0, dir, collection_search);
// Override metadata with what was in the playlist // Override metadata with what was in the playlist
if (song.source() != Song::Source_Collection) { if (song.source() != Song::Source_Collection) {

View File

@ -48,11 +48,11 @@ class XSPFParser : public XMLParser {
bool TryMagic(const QByteArray &data) const override; bool TryMagic(const QByteArray &data) const override;
SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir()) const override; SongList Load(QIODevice *device, const QString &playlist_path = "", const QDir &dir = QDir(), const bool collection_search = true) const override;
void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override; void Save(const SongList &songs, QIODevice *device, const QDir &dir = QDir(), Playlist::Path path_type = Playlist::Path_Automatic) const override;
private: private:
Song ParseTrack(QXmlStreamReader *reader, const QDir &dir) const; Song ParseTrack(QXmlStreamReader *reader, const QDir &dir, const bool collection_search) const;
}; };
#endif #endif

View File

@ -80,6 +80,10 @@ CollectionSettingsPage::CollectionSettingsPage(SettingsDialog *dialog)
QObject::connect(ui_->add, &QPushButton::clicked, this, &CollectionSettingsPage::Add); QObject::connect(ui_->add, &QPushButton::clicked, this, &CollectionSettingsPage::Add);
QObject::connect(ui_->remove, &QPushButton::clicked, this, &CollectionSettingsPage::Remove); QObject::connect(ui_->remove, &QPushButton::clicked, this, &CollectionSettingsPage::Remove);
#ifdef HAVE_SONGFINGERPRINTING
QObject::connect(ui_->song_tracking, &QCheckBox::toggled, this, &CollectionSettingsPage::SongTrackingToggled);
#endif
QObject::connect(ui_->radiobutton_save_albumcover_albumdir, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged); QObject::connect(ui_->radiobutton_save_albumcover_albumdir, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged);
QObject::connect(ui_->radiobutton_cover_hash, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged); QObject::connect(ui_->radiobutton_cover_hash, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged);
QObject::connect(ui_->radiobutton_cover_pattern, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged); QObject::connect(ui_->radiobutton_cover_pattern, &QRadioButton::toggled, this, &CollectionSettingsPage::CoverSaveInAlbumDirChanged);
@ -91,6 +95,10 @@ CollectionSettingsPage::CollectionSettingsPage(SettingsDialog *dialog)
QObject::connect(ui_->combobox_cache_size, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CollectionSettingsPage::CacheSizeUnitChanged); QObject::connect(ui_->combobox_cache_size, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CollectionSettingsPage::CacheSizeUnitChanged);
QObject::connect(ui_->combobox_disk_cache_size, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CollectionSettingsPage::DiskCacheSizeUnitChanged); QObject::connect(ui_->combobox_disk_cache_size, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CollectionSettingsPage::DiskCacheSizeUnitChanged);
#ifndef HAVE_SONGFINGERPRINTING
ui_->song_tracking->hide();
#endif
} }
CollectionSettingsPage::~CollectionSettingsPage() { delete ui_; } CollectionSettingsPage::~CollectionSettingsPage() { delete ui_; }
@ -124,6 +132,15 @@ void CollectionSettingsPage::CurrentRowChanged(const QModelIndex &idx) {
ui_->remove->setEnabled(idx.isValid()); ui_->remove->setEnabled(idx.isValid());
} }
void CollectionSettingsPage::SongTrackingToggled() {
ui_->mark_songs_unavailable->setEnabled(!ui_->song_tracking->isChecked());
if (ui_->song_tracking->isChecked()) {
ui_->mark_songs_unavailable->setChecked(true);
}
}
void CollectionSettingsPage::DiskCacheEnable(const int state) { void CollectionSettingsPage::DiskCacheEnable(const int state) {
bool checked = state == Qt::Checked; bool checked = state == Qt::Checked;
@ -157,7 +174,9 @@ void CollectionSettingsPage::Load() {
ui_->show_dividers->setChecked(s.value("show_dividers", true).toBool()); ui_->show_dividers->setChecked(s.value("show_dividers", true).toBool());
ui_->startup_scan->setChecked(s.value("startup_scan", true).toBool()); ui_->startup_scan->setChecked(s.value("startup_scan", true).toBool());
ui_->monitor->setChecked(s.value("monitor", true).toBool()); ui_->monitor->setChecked(s.value("monitor", true).toBool());
ui_->mark_songs_unavailable->setChecked(s.value("mark_songs_unavailable", false).toBool()); ui_->song_tracking->setChecked(s.value("song_tracking", false).toBool());
ui_->mark_songs_unavailable->setChecked(ui_->song_tracking->isChecked() ? true : s.value("mark_songs_unavailable", true).toBool());
ui_->expire_unavailable_songs_days->setValue(s.value("expire_unavailable_songs", 60).toInt());
QStringList filters = s.value("cover_art_patterns", QStringList() << "front" << "cover").toStringList(); QStringList filters = s.value("cover_art_patterns", QStringList() << "front" << "cover").toStringList();
ui_->cover_art_patterns->setText(filters.join(",")); ui_->cover_art_patterns->setText(filters.join(","));
@ -216,7 +235,9 @@ void CollectionSettingsPage::Save() {
s.setValue("show_dividers", ui_->show_dividers->isChecked()); s.setValue("show_dividers", ui_->show_dividers->isChecked());
s.setValue("startup_scan", ui_->startup_scan->isChecked()); s.setValue("startup_scan", ui_->startup_scan->isChecked());
s.setValue("monitor", ui_->monitor->isChecked()); s.setValue("monitor", ui_->monitor->isChecked());
s.setValue("mark_songs_unavailable", ui_->mark_songs_unavailable->isChecked()); s.setValue("song_tracking", ui_->song_tracking->isChecked());
s.setValue("mark_songs_unavailable", ui_->song_tracking->isChecked() ? true : ui_->mark_songs_unavailable->isChecked());
s.setValue("expire_unavailable_songs", ui_->expire_unavailable_songs_days->value());
QString filter_text = ui_->cover_art_patterns->text(); QString filter_text = ui_->cover_art_patterns->text();

View File

@ -76,6 +76,7 @@ class CollectionSettingsPage : public SettingsPage {
void Remove(); void Remove();
void CurrentRowChanged(const QModelIndex &idx); void CurrentRowChanged(const QModelIndex &idx);
void SongTrackingToggled();
void DiskCacheEnable(const int state); void DiskCacheEnable(const int state);
void CoverSaveInAlbumDirChanged(); void CoverSaveInAlbumDirChanged();
void ClearPixmapDiskCache(); void ClearPixmapDiskCache();

View File

@ -92,6 +92,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QCheckBox" name="song_tracking">
<property name="text">
<string>Song fingerprinting and tracking</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QCheckBox" name="mark_songs_unavailable"> <widget class="QCheckBox" name="mark_songs_unavailable">
<property name="text"> <property name="text">
@ -99,6 +106,73 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QWidget" name="widget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_expire_unavailable_songs_after">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Expire unavailable songs after</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="expire_unavailable_songs_days">
<property name="maximum">
<number>365</number>
</property>
<property name="value">
<number>60</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_expire_unavailable_songs_days">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>days</string>
</property>
</widget>
</item>
<item>
<spacer name="spacer_expire_unavailable_songs">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item> <item>
<widget class="QLabel" name="label_preferred_cover_filenames"> <widget class="QLabel" name="label_preferred_cover_filenames">
<property name="text"> <property name="text">