Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Library: Add last_played_at column #3140

Merged
merged 25 commits into from
Nov 13, 2020
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3df10ad
Store (transient) last played time stamp in PlayCounter
uklotzde Sep 28, 2020
ef647fc
PlayCounter: Populate lastPlayedAt from database on load
uklotzde Sep 28, 2020
ab83cd1
Add last_played_at column to library table
uklotzde Sep 29, 2020
f936a34
Add "lastplayed" search field
uklotzde Sep 29, 2020
43e5e63
Use library.last_played_at for AutoDj
uklotzde Sep 29, 2020
3ecff63
PlayCounter: Rename played flag and fix outdated comments
uklotzde Sep 30, 2020
98cc65f
Create indexes for tracks in playlists and crates
uklotzde Sep 30, 2020
ab7f187
Log composed SQL query on demand
uklotzde Sep 29, 2020
2ae67da
Update required schema version
uklotzde Sep 30, 2020
cc48052
Delete obsolete code after reverting unintended changes
uklotzde Sep 30, 2020
7c3b16c
Delete redundant '='
uklotzde Sep 30, 2020
4f9d766
Update play counter after removing tracks from playlists
uklotzde Sep 30, 2020
66ea808
TrackDAO: Delete more obsolete left-over code
uklotzde Sep 30, 2020
46a9857
Explicitly update play counter from played history
uklotzde Oct 1, 2020
cd86537
Merge branch 'master' into library_last_played_at
uklotzde Oct 16, 2020
4fc531e
Add missing receiver context to signal/slot connection
uklotzde Oct 18, 2020
0625288
Merge branch 'master' into library_last_played_at
uklotzde Oct 20, 2020
a01c978
Merge branch 'main' into library_last_played_at
uklotzde Oct 22, 2020
5e40a18
Merge branch 'main' into library_last_played_at
uklotzde Oct 24, 2020
8baa952
Merge branch 'main' into library_last_played_at
uklotzde Oct 29, 2020
36b2dd5
Add missing emit keywords
uklotzde Oct 29, 2020
b4b96fe
Merge branch 'main' into library_last_played_at
uklotzde Nov 7, 2020
582b43f
Merge branch 'main' into library_last_played_at
uklotzde Nov 12, 2020
59371b6
Merge branch 'main' into library_last_played_at
uklotzde Nov 13, 2020
4ee3e85
Merge branch 'main' of git@github.com:mixxxdj/mixxx.git into library_…
uklotzde Nov 13, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions res/schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -503,4 +503,38 @@ METADATA
ALTER TABLE library ADD COLUMN coverart_digest BLOB;
</sql>
</revision>
<revision version="34" min_compatible="3">
<description>
Add indexes for tracks in playlists and crates
</description>
<sql>
CREATE INDEX IF NOT EXISTS idx_PlaylistTracks_playlist_id_track_id ON PlaylistTracks (
playlist_id,
track_id
);
CREATE INDEX IF NOT EXISTS idx_PlaylistTracks_track_id ON PlaylistTracks (
track_id
);
CREATE INDEX IF NOT EXISTS idx_crate_tracks_track_id ON crate_tracks (
track_id
);
</sql>
</revision>
<revision version="35" min_compatible="3">
<description>
Add last_played_at column to library table
</description>
<sql>
-- Add new column
ALTER TABLE library ADD COLUMN last_played_at DATETIME DEFAULT NULL;
-- Populate new column from history playlists
UPDATE library SET last_played_at=(
daschuer marked this conversation as resolved.
Show resolved Hide resolved
SELECT MAX(PlaylistTracks.pl_datetime_added)
FROM PlaylistTracks
JOIN Playlists ON PlaylistTracks.playlist_id=Playlists.id
WHERE PlaylistTracks.track_id=library.id
AND Playlists.hidden=2
GROUP BY PlaylistTracks.track_id);
</sql>
</revision>
</schema>
4 changes: 1 addition & 3 deletions src/database/mixxxdb.cpp
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
#include "database/mixxxdb.h"

#include "database/schemamanager.h"

#include "util/assert.h"
#include "util/logger.h"


// The schema XML is baked into the binary via Qt resources.
//static
const QString MixxxDb::kDefaultSchemaFile(":/schema.xml");

//static
const int MixxxDb::kRequiredSchemaVersion = 33;
const int MixxxDb::kRequiredSchemaVersion = 35;
uklotzde marked this conversation as resolved.
Show resolved Hide resolved

namespace {

Expand Down
66 changes: 37 additions & 29 deletions src/library/dao/autodjcratesdao.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
#include "mixer/playerinfo.h"
#include "mixer/playermanager.h"

#if !defined(VERBOSE_DEBUG_LOG)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please don't invent a new log level. "debug" is our verbose level, just use that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this to prevent log spam. DEBUG is currently used as INFO. Explained here #2782. Otherwise I would have to delete all those extra logs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that we don't have a good story for Info vs Debug. But this feels like a one-off change that will increase tech debt. If you are going to do this, please create a bug and assign it to yourself to remove this special casing once we have better logging code.

// set to true for verbose debug logs
#define VERBOSE_DEBUG_LOG false
#endif

#define AUTODJCRATESTABLE_TRACKID "track_id"
#define AUTODJCRATESTABLE_CRATEREFS "craterefs"
#define AUTODJCRATESTABLE_TIMESPLAYED "timesplayed"
Expand Down Expand Up @@ -138,35 +143,38 @@ void AutoDJCratesDAO::createAndConnectAutoDjCratesDatabase() {
return;
}

// Fill out the first three columns.
// Supply default values for the last two.
// INSERT INTO temp_autodj_crates (
// track_id, craterefs, timesplayed, autodjrefs, lastplayed)
// SELECT crate_tracks.track_id, COUNT (*), library.timesplayed, 0, ""
// FROM crate_tracks, library
// WHERE crate_tracks.crate_id IN (
// SELECT id
// FROM crates
// WHERE autodj = 1)
// AND crate_tracks.track_id = library.id
// AND library.mixxx_deleted = 0
// GROUP BY crate_tracks.track_id, library.timesplayed;
daschuer marked this conversation as resolved.
Show resolved Hide resolved
strQuery = QString("INSERT INTO " AUTODJCRATES_TABLE
" (" AUTODJCRATESTABLE_TRACKID ", " AUTODJCRATESTABLE_CRATEREFS ", "
AUTODJCRATESTABLE_TIMESPLAYED ", " AUTODJCRATESTABLE_AUTODJREFS ", "
AUTODJCRATESTABLE_LASTPLAYED ") SELECT " CRATE_TRACKS_TABLE
".%1 , COUNT (*), " LIBRARY_TABLE ".%2, 0, \"\" FROM "
CRATE_TRACKS_TABLE ", " LIBRARY_TABLE " WHERE " CRATE_TRACKS_TABLE
".%4 IN (SELECT %5 FROM " CRATE_TABLE " WHERE %6 = 1) AND "
CRATE_TRACKS_TABLE ".%1 = " LIBRARY_TABLE ".%7 AND " LIBRARY_TABLE
".%3 == 0 GROUP BY " CRATE_TRACKS_TABLE ".%1, " LIBRARY_TABLE ".%2")
.arg(CRATETRACKSTABLE_TRACKID, // %1
LIBRARYTABLE_TIMESPLAYED, // %2
LIBRARYTABLE_MIXXXDELETED, // %3
CRATETRACKSTABLE_CRATEID, // %4
CRATETABLE_ID, // %5
CRATETABLE_AUTODJ_SOURCE, // %6
LIBRARYTABLE_ID); // %7
strQuery = QStringLiteral(
"INSERT INTO " AUTODJCRATES_TABLE "(" AUTODJCRATESTABLE_TRACKID
"," AUTODJCRATESTABLE_CRATEREFS "," AUTODJCRATESTABLE_TIMESPLAYED
"," AUTODJCRATESTABLE_LASTPLAYED "," AUTODJCRATESTABLE_AUTODJREFS
") SELECT " CRATE_TRACKS_TABLE
".%5," // TRACKID
"COUNT(*)," // CRATEREFS
LIBRARY_TABLE ".%2," // TIMESPLAYED
LIBRARY_TABLE
".%3," // LASTPLAYED
"0" // AUTODJREFS = default
" FROM " CRATE_TRACKS_TABLE
" INNER JOIN " LIBRARY_TABLE " ON " LIBRARY_TABLE ".%1=" CRATE_TRACKS_TABLE
".%5"
" WHERE " LIBRARY_TABLE
".%4=0"
" AND " CRATE_TRACKS_TABLE ".%6 IN (SELECT %7 FROM " CRATE_TABLE
" WHERE %8=1)"
" GROUP BY " CRATE_TRACKS_TABLE ".%5")
.arg(LIBRARYTABLE_ID, // %1
LIBRARYTABLE_TIMESPLAYED, // %2
LIBRARYTABLE_LAST_PLAYED_AT, // %3
LIBRARYTABLE_MIXXXDELETED, // %4
CRATETRACKSTABLE_TRACKID, // %5
CRATETRACKSTABLE_CRATEID, // %6
CRATETABLE_ID, // %7
CRATETABLE_AUTODJ_SOURCE); // %8
#if !defined(VERBOSE_DEBUG_LOG)
qDebug().noquote()
<< "Populating " AUTODJCRATES_TABLE " using the following SQL query:"
<< strQuery;
#endif
oQuery.prepare(strQuery);
if (!oQuery.exec()) {
LOG_FAILED_QUERY(oQuery);
Expand Down
5 changes: 4 additions & 1 deletion src/library/dao/playlistdao.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ void PlaylistDAO::deletePlaylist(const int playlistId) {
//qDebug() << "PlaylistDAO::deletePlaylist" << QThread::currentThread() << m_database.connectionName();
ScopedTransaction transaction(m_database);

const HiddenType hiddenType = getHiddenType(playlistId);
const QList<TrackId> trackIds = getTrackIds(playlistId);

// Get the playlist id for this
QSqlQuery query(m_database);

Expand Down Expand Up @@ -205,7 +208,7 @@ void PlaylistDAO::deletePlaylist(const int playlistId) {
}
}

emit deleted(playlistId);
emit deleted(playlistId, hiddenType, trackIds);
}

void PlaylistDAO::renamePlaylist(const int playlistId, const QString& newName) {
Expand Down
2 changes: 1 addition & 1 deletion src/library/dao/playlistdao.h
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class PlaylistDAO : public QObject, public virtual DAO {

signals:
void added(int playlistId);
void deleted(int playlistId);
void deleted(int playlistId, HiddenType hiddenType, const QList<TrackId>& trackIds);
void renamed(int playlistId, QString newName);
void lockChanged(int playlistId);
void trackAdded(int playlistId, TrackId trackId, int position);
Expand Down
87 changes: 86 additions & 1 deletion src/library/dao/trackdao.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
#include "util/assert.h"
#include "util/compatibility.h"
#include "util/datetime.h"
#include "util/db/fwdsqlquery.h"
#include "util/db/sqllikewildcardescaper.h"
#include "util/db/sqllikewildcards.h"
#include "util/db/sqlstringformatter.h"
Expand Down Expand Up @@ -57,6 +58,15 @@ void markTrackLocationsAsDeleted(QSqlDatabase database, const QString& directory
}
}

QString joinTrackIdList(const QSet<TrackId>& trackIds) {
QStringList trackIdList;
trackIdList.reserve(trackIds.size());
for (const auto& trackId : trackIds) {
trackIdList.append(trackId.toString());
}
return trackIdList.join(QChar(','));
uklotzde marked this conversation as resolved.
Show resolved Hide resolved
}

} // anonymous namespace

TrackDAO::TrackDAO(CueDAO& cueDao,
Expand All @@ -72,6 +82,36 @@ TrackDAO::TrackDAO(CueDAO& cueDao,
m_trackLocationIdColumn(UndefinedRecordIndex),
m_queryLibraryIdColumn(UndefinedRecordIndex),
m_queryLibraryMixxxDeletedColumn(UndefinedRecordIndex) {
connect(&m_playlistDao,
uklotzde marked this conversation as resolved.
Show resolved Hide resolved
&PlaylistDAO::deleted,
[this](int playlistId, PlaylistDAO::HiddenType hiddenType, const QList<TrackId>& trackIds) {
Q_UNUSED(playlistId)
if (hiddenType != PlaylistDAO::PLHT_SET_LOG) {
// Nothing to do
return;
}
QSet<TrackId> trackIdSet;
for (const auto& trackId : trackIds) {
trackIdSet.insert(trackId);
}
VERIFY_OR_DEBUG_ASSERT(updatePlayCounterFromHistoryPlaylists(trackIdSet)) {
return;
}
});
connect(&m_playlistDao,
&PlaylistDAO::trackRemoved,
[this](int playlistId, TrackId trackId, int position) {
Q_UNUSED(position)
if (m_playlistDao.getHiddenType(playlistId) != PlaylistDAO::PLHT_SET_LOG) {
// Nothing to do
return;
}
QSet<TrackId> trackIdSet;
trackIdSet.insert(trackId);
VERIFY_OR_DEBUG_ASSERT(updatePlayCounterFromHistoryPlaylists(trackIdSet)) {
return;
}
});
}

TrackDAO::~TrackDAO() {
Expand Down Expand Up @@ -373,6 +413,7 @@ void TrackDAO::addTracksPrepare() {
"replaygain_peak,"
"wavesummaryhex,"
"timesplayed,"
"last_played_at,"
"played,"
"mixxx_deleted,"
"header_parsed,"
Expand Down Expand Up @@ -419,6 +460,7 @@ void TrackDAO::addTracksPrepare() {
":replaygain_peak,"
":wavesummaryhex,"
":timesplayed,"
":last_played_at,"
":played,"
":mixxx_deleted,"
":header_parsed,"
Expand Down Expand Up @@ -532,6 +574,7 @@ void bindTrackLibraryValues(

const PlayCounter& playCounter = track.getPlayCounter();
pTrackLibraryQuery->bindValue(":timesplayed", playCounter.getTimesPlayed());
pTrackLibraryQuery->bindValue(":last_played_at", playCounter.getLastPlayedAt());
pTrackLibraryQuery->bindValue(":played", playCounter.isPlayed() ? 1 : 0);

const CoverInfoRelative& coverInfo = track.getCoverInfo();
Expand Down Expand Up @@ -1142,7 +1185,14 @@ bool setTrackTimesPlayed(const QSqlRecord& record, const int column,
bool setTrackPlayed(const QSqlRecord& record, const int column,
TrackPointer pTrack) {
PlayCounter playCounter(pTrack->getPlayCounter());
playCounter.setPlayed(record.value(column).toBool());
playCounter.setPlayedFlag(record.value(column).toBool());
pTrack->setPlayCounter(playCounter);
return false;
}

bool setTrackLastPlayedAt(const QSqlRecord& record, const int column, TrackPointer pTrack) {
PlayCounter playCounter(pTrack->getPlayCounter());
playCounter.setLastPlayedAt(record.value(column).toDateTime());
pTrack->setPlayCounter(playCounter);
return false;
}
Expand Down Expand Up @@ -1291,6 +1341,7 @@ TrackPointer TrackDAO::getTrackById(TrackId trackId) const {
{"replaygain", setTrackReplayGainRatio},
{"replaygain_peak", setTrackReplayGainPeak},
{"timesplayed", setTrackTimesPlayed},
{"last_played_at", setTrackLastPlayedAt},
{"played", setTrackPlayed},
{"datetime_added", setTrackDateAdded},
{"header_parsed", setTrackMetadataSynchronized},
Expand Down Expand Up @@ -1551,6 +1602,7 @@ bool TrackDAO::updateTrack(Track* pTrack) const {
"replaygain=:replaygain,"
"replaygain_peak=:replaygain_peak,"
"timesplayed=:timesplayed,"
"last_played_at=:last_played_at,"
"played=:played,"
"header_parsed=:header_parsed,"
"channels=:channels,"
Expand Down Expand Up @@ -2137,3 +2189,36 @@ TrackFile TrackDAO::relocateCachedTrack(
return TrackFile(trackLocation);
}
}

bool TrackDAO::updatePlayCounterFromHistoryPlaylists(
const QSet<TrackId> trackIds) const {
FwdSqlQuery query(
m_database,
QStringLiteral(
uklotzde marked this conversation as resolved.
Show resolved Hide resolved
"UPDATE library SET "
"timesplayed=q.timesplayed,"
"last_played_at=q.last_played_at "
"FROM("
"SELECT "
"PlaylistTracks.track_id as id,"
"COUNT(PlaylistTracks.track_id) as timesplayed,"
"MAX(PlaylistTracks.pl_datetime_added) as last_played_at "
"FROM PlaylistTracks "
"JOIN Playlists ON "
"PlaylistTracks.playlist_id=Playlists.id "
"WHERE Playlists.hidden=%2 "
"GROUP BY PlaylistTracks.track_id"
") q "
"WHERE library.id=q.id "
"AND library.id IN (%1)")
.arg(joinTrackIdList(trackIds),
QString::number(PlaylistDAO::PLHT_SET_LOG)));
VERIFY_OR_DEBUG_ASSERT(!query.hasError()) {
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need also consider the played flag.

Maybe we can dispose it internally by comparing the current play count with the value the session starts. That sounds less redundant.

I did not have a look, but we have also a feature to join two set logs, the played flag needs to be considered than as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The played flag should not be a property of the (persistent) track object. The set of tracks that are considered as (manually) played during a session should be managed separately. The complicated and inconsistent logic in the PlayCounter is already screaming out loud. I refuse to add more magic to it that fits some use cases but induces unexpected behavior for others. If the user decides to rewrite history they could be bothered with updating the played flag manually. It will be reset anyway at some time.

For my aoide view I don't have a played flag and already use the logic you proposed for an implicit played flag. This could be overridden with a session played flag, once Mixxx supports this concept.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the played flag is a slightly different idea and should remain separate. (Also note that Played status is maintained across crashes so the user doesn't lose state :))

Copy link
Contributor Author

@uklotzde uklotzde Oct 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mangling the played flag as a column of the Track entity table was a bad idea. Instead the set of played tracks must be stored separately by introducing the concept of a Session entity. Out of scope of this PR.

VERIFY_OR_DEBUG_ASSERT(query.execPrepared()) {
return false;
}
emit tracksChanged(trackIds);
return true;
}
6 changes: 5 additions & 1 deletion src/library/dao/trackdao.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include "util/class.h"
#include "util/memory.h"

class FwdSqlQuery;
class SqlTransaction;
class PlaylistDAO;
class AnalysisDao;
Expand Down Expand Up @@ -69,6 +70,9 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC
// Only used by friend class TrackCollection, but public for testing!
void saveTrack(Track* pTrack) const;

bool updatePlayCounterFromHistoryPlaylists(
const QSet<TrackId> trackIds) const;

signals:
// Forwarded from Track object
void trackDirty(TrackId trackId) const;
Expand Down Expand Up @@ -160,7 +164,7 @@ class TrackDAO : public QObject, public virtual DAO, public virtual GlobalTrackC
AnalysisDao& m_analysisDao;
LibraryHashDAO& m_libraryHashDao;

UserSettingsPointer m_pConfig;
const UserSettingsPointer m_pConfig;

std::unique_ptr<QSqlQuery> m_pQueryTrackLocationInsert;
std::unique_ptr<QSqlQuery> m_pQueryTrackLocationSelect;
Expand Down
1 change: 1 addition & 0 deletions src/library/dao/trackschema.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const QString LIBRARYTABLE_MIXXXDELETED = QStringLiteral("mixxx_deleted");
const QString LIBRARYTABLE_DATETIMEADDED = QStringLiteral("datetime_added");
const QString LIBRARYTABLE_HEADERPARSED = QStringLiteral("header_parsed");
const QString LIBRARYTABLE_TIMESPLAYED = QStringLiteral("timesplayed");
const QString LIBRARYTABLE_LAST_PLAYED_AT = QStringLiteral("last_played_at");
const QString LIBRARYTABLE_PLAYED = QStringLiteral("played");
const QString LIBRARYTABLE_RATING = QStringLiteral("rating");
const QString LIBRARYTABLE_KEY = QStringLiteral("key");
Expand Down
1 change: 1 addition & 0 deletions src/library/searchqueryparser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ SearchQueryParser::SearchQueryParser(TrackCollection* pTrackCollection)
m_fieldToSqlColumns["key"] << "key";
m_fieldToSqlColumns["key_id"] << "key_id";
m_fieldToSqlColumns["played"] << "timesplayed";
m_fieldToSqlColumns["lastplayed"] << "last_played_at";
m_fieldToSqlColumns["rating"] << "rating";
m_fieldToSqlColumns["location"] << "location";
m_fieldToSqlColumns["datetime_added"] << "datetime_added";
Expand Down
2 changes: 1 addition & 1 deletion src/library/trackset/setlogfeature.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ void SetlogFeature::slotJoinWithPrevious() {
m_pPlaylistTableModel->getTrack(index);
// Do not update the play count, just set played status.
PlayCounter playCounter(track->getPlayCounter());
playCounter.setPlayed();
playCounter.triggerLastPlayedNow();
track->setPlayCounter(playCounter);
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/test/playcountertest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class PlayCounterTest : public testing::Test {
void updatePlayedAndVerify(PlayCounter* pPlayCounter, bool bPlayed) {
bool isPlayedBefore = pPlayCounter->isPlayed();
int timesPlayedBefore = pPlayCounter->getTimesPlayed();
pPlayCounter->setPlayedAndUpdateTimesPlayed(bPlayed);
pPlayCounter->updateLastPlayedNowAndTimesPlayed(bPlayed);
bool isPlayedAfter = pPlayCounter->isPlayed();
int timesPlayedAfter = pPlayCounter->getTimesPlayed();
if (bPlayed) {
Expand Down Expand Up @@ -46,9 +46,10 @@ class PlayCounterTest : public testing::Test {
updatePlayedAndVerify(pPlayCounter, true);
updatePlayedAndVerify(pPlayCounter, true);
updatePlayedAndVerify(pPlayCounter, false);
EXPECT_EQ(PlayCounter(1), *pPlayCounter);
EXPECT_EQ(1, pPlayCounter->getTimesPlayed());
EXPECT_FALSE(pPlayCounter->isPlayed());
resetAndVerify(pPlayCounter);
EXPECT_EQ(PlayCounter(), *pPlayCounter);
ASSERT_EQ(PlayCounter(), *pPlayCounter);
}
};

Expand Down
Loading