Your music history lives in your spreadsheet — every song you've ever listened to, all in one place, with no limits. Last.fm tracks your plays automatically, and you keep full control: edit, add, or explore your data however you like. Full setup in the setup guide.
02FASTEST START
Last.fm
Load your full scrobble history directly from Last.fm
New to Last.fm? Learn more
Last.fm automatically tracks every song you listen to — this is called scrobbling. Over time it builds a complete history of your music taste that you can chart here.
Compatible with:
SpotifyApple MusicYouTube MusicTidalDeezerAmazon MusicSoundCloudPlexSubsonic+ many moreSpotifyApple MusicYouTube MusicTidalDeezerAmazon MusicSoundCloudPlexSubsonic+ many more
Your listening history. Your charts. Your life's soundtrack.
Streaming since January 9, 2016
📂
Load Your Listening History
Drop your CSV file here, or click to browse
⟳ Loading…
⏳ TIME MACHINE
collapsed — click + to expand
On this day in past years
▶ Click any card for options — Spotify, player queue, or Google
Chart Views
Loading...
←→arrow keys to navigate periods◀swipe to navigate▶
Top 10 Songs
collapsed — click + to expand
By play count for the selected period
RANK
Title · Artist
Album
Plays
· pg
EXPORT:
BUBBLING UNDERSongs0▼
Songs just outside the chart — ranked by plays this week
Off the Chart★ Songs0▼
New Songs▲collapsed — click ✦ to expand
RANK
Title · Artist
Plays
· pg
Top 10 Artists
collapsed — click + to expand
By total play count across all songs
RANK
Artist
Unique Songs
Total Plays
· pg
EXPORT:
BUBBLING UNDERArtists0▼
Artists just outside the chart — ranked by plays this week
Off the Chart♦ Artists0▼
New Artists▲collapsed — click ✦ to expand
RANK
Artist
Plays
· pg
Top 10 Albums
collapsed — click + to expand
By total play count across all tracks
RANK
Album · Artist
Tracks
Total Plays
· pg
EXPORT:
BUBBLING UNDERAlbums0▼
Albums just outside the chart — ranked by plays this week
Off the Chart◈ Albums0▼
New Albums▲collapsed — click ✦ to expand
RANK
Album · Artist
Plays
· pg
🔜 Upcoming Releases
collapsed — click + to expand
Next 90 days · Based on your Top 200
All-Time Artists
🎉 Recent Releases
collapsed — click + to expand
Past 180 days · Based on your Top 200 All-Time
Artists
📈 Historical Graphs
📈 Cumulative Plays Over Time — Artist
Comparison
Rangeto
📊 Total Play Volume by Period
How many songs you played each period across all
artists
Rangeto
📊 Play Volume by Period — Artist Comparison
Rangeto
✨ New Discoveries per Period
Songs, artists and albums heard for the first time
each period
🏁 Play Count Race
Cumulative plays over time — watch entries compete for
the top spot
Type
Speed
Show
Rangeto
🔥 Listening Activity Heatmap
Your music mapped day by day — brighter squares mean more plays. Hover any square to explore what you were listening to.
View
⊞ Raw Scrobble Data
#↕
Date & Time↓
Song Title↕
Artist↕
Album↕
🏆 Records & Hall of Fame
Load your data to see records.
🔎
★ All #1s
Artists, albums & tracks that reached the top spot on
your charts — including how many times each held #1
🎯 Perfect All Kill
Weeks an artist simultaneously held #1 on the artist, song
& album weekly charts
📈 Most Chart Appearances
Artists, albums & tracks with the most periods
spent inside your weekly chart
🚀 Biggest Debuts
Entries that debuted at the highest chart positions on the
weekly chart
🔥 Most Plays in a Period
Peak single-period play counts — most plays by a song,
artist or album in one week, month or year
🎖 Play Count Milestones
First artists & songs to reach 10, 25, 50, 100, 250, 500, 1,000, 2,500, 5,000+ plays
⚡ Fastest to Milestone
Which artists & songs hit milestones fastest — fewest
days from first listen to reaching 1K, 2K, 5K plays
💿 Certifications Leaderboard
Artists with the most Gold, Platinum & Diamond certified
songs and albums
🔁 Streak Records
Longest consecutive listening streaks, repeat plays, and
daily play records
🆕 New Charts Records
Records accomplished on the New Songs, New Artists & New Albums charts — counted
only from entries that actually charted at your current New Charts size
🗓 Events Calendar
Top
all-time artists
Type:
🎂 Artist Birthdays
collapsed — click + to expand
Upcoming birthdays for your top artists — next 30 days
🎵 Anniversaries
collapsed — click + to expand
Releases celebrating anniversaries in the next 30 days
🆕 Upcoming Releases
collapsed — click + to expand
Releases coming in the next 90 days from your top artists
📅 Recent Releases
collapsed — click + to expand
Releases from the past 6 months from your top artists
🎂 Recent Birthdays
collapsed — click + to expand
Artist birthdays from the past 30 days you may have missed
🎵 Recent Anniversaries
collapsed — click + to expand
Album anniversaries from the past 30 days you may have missed
🎧 New Music Friday
collapsed — click + to expand
New releases this week from Deezer · not filtered to your artists
Fridays with stored releases are highlighted — click to view that week
B1 = your Last.fm username, B2 = your API key (free).
The tab and the script both came with the template. Nothing to paste.
Brought your own sheet? Start here
1 · Add the script
Open Extensions → Apps Script, replace everything in the editor with this, and save with Ctrl+S. Same steps if you're replacing an older copy of the script — redeploy afterwards if you had already set up Add Play.
// =============================================================
// DANKCHARTS.FM — Google Sheet Sync Script
// =============================================================
// SETUP (do this once):
// 1. Fill in your details in the "Settings" tab:
// B1 = Your Last.fm username
// B2 = Your Last.fm API key (free: last.fm/api/account/create)
// B3 = Data tab name (default: Full Raw Listening History)
//
// 2. FOR AUTO-SYNC: click the button next to "Help" in the Google Sheets
// menu bar called "Last.fm" → select "Start Auto-Update (90 min)"
// It will sync new plays every 90 minutes.
//
// 3. FOR MANUAL ENTRY: Deploy this script as a Web App
// (Deploy → New deployment → Web app → Execute as: Me → Access: Anyone)
// then paste the generated URL into the dankcharts.fm settings.
//
// NOTE — "This app isn't verified" warning:
// Google shows this screen the first time you authorize the script.
// It is normal for personal scripts. Click Advanced →
// "Go to [app name] (unsafe)" → Allow. It is safe because you
// own this script and it only accesses your own Google Sheet.
// =============================================================
// Reads your credentials from the Settings tab.
// No need to edit this function.
function getSettings_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var s = ss.getSheetByName('Settings');
// No Settings tab: this sheet didn't come from the dankcharts template.
// Build the tab, labelled, instead of failing with a name and three cell
// positions for you to guess at.
if (!s) {
s = ss.insertSheet('Settings');
s.getRange('A1:A3').setValues([
['Last.fm username'],
['Last.fm API key'],
['Data tab name (optional)']
]);
s.getRange('A1:A3').setFontWeight('bold');
s.setColumnWidth(1, 200);
s.setColumnWidth(2, 340);
s.getRange('B2').setNote('Get a free API key at last.fm/api/account/create');
s.getRange('B3').setNote('Leave empty to use "Full Raw Listening History"');
throw new Error('A "Settings" tab has just been created in this spreadsheet. Fill in B1 (your Last.fm username) and B2 (your API key), then run this again.');
}
return {
user: s.getRange('B1').getValue().toString().trim(),
apiKey: s.getRange('B2').getValue().toString().trim(),
tabName: s.getRange('B3').getValue().toString().trim() || 'Full Raw Listening History'
};
}
// Removes rows that have no song/artist data but a pre-2000 date in column D.
// These ghost rows (epoch-zero timestamps) accumulate when Last.fm returns
// incomplete scrobbles and corrupt future syncs.
function cleanupGhostRows_(sheet) {
var lastRow = sheet.getLastRow();
if (lastRow < 2) return;
var vals = sheet.getRange(2, 1, lastRow - 1, 4).getValues();
var toDelete = [];
for (var i = 0; i < vals.length; i++) {
var title = vals[i][0], artist = vals[i][1], album = vals[i][2], dt = vals[i][3];
if (!title && !artist && !album && dt instanceof Date && dt.getFullYear() < 2000) {
toDelete.push(i + 2); // convert to 1-indexed sheet row
}
}
// Delete from bottom to top so row numbers stay valid
for (var j = toDelete.length - 1; j >= 0; j--) {
sheet.deleteRow(toDelete[j]);
}
if (toDelete.length) Logger.log('Cleaned up ' + toDelete.length + ' ghost row(s).');
}
// =============================================================
// AUTO-SYNC
// Fetches any new plays from Last.fm and appends them to your
// data tab. Runs every 90 minutes once you click
// Last.fm → Start Auto-Update in the sheet menu.
// =============================================================
function fetchAndLogLastFmHistory() {
var cfg = getSettings_();
// Stop if credentials are missing
if (!cfg.user || !cfg.apiKey) {
Logger.log('ERROR: Fill in your Last.fm Username (B1) and API Key (B2) in the Settings tab.');
return;
}
// Remove any existing triggers to avoid duplicates (Google limits you to 20)
ScriptApp.getProjectTriggers().forEach(function(t) {
if (t.getHandlerFunction() === 'fetchAndLogLastFmHistory') ScriptApp.deleteTrigger(t);
});
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(cfg.tabName);
if (!sheet) {
Logger.log('ERROR: Tab "' + cfg.tabName + '" not found. Check B3 in the Settings tab.');
return;
}
// Use the sheet's own timezone so dates are stored in your local time
var tz = ss.getSpreadsheetTimeZone();
// Remove ghost rows (no song data, epoch-zero date) before scanning for lastTs
cleanupGhostRows_(sheet);
// Find the last recorded track so we only fetch plays newer than that
var maxRow = sheet.getLastRow(), lastTs = 0, actualLastRow = 0;
if (maxRow > 1) {
var vals = sheet.getRange(1, 4, maxRow, 1).getValues(); // column D = date
for (var i = vals.length - 1; i >= 0; i--) {
var v = vals[i][0];
if (v && v !== '') {
var str = v instanceof Date ? Utilities.formatDate(v, tz, 'yyyy-MM-dd HH:mm:ss') : String(v);
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(str)) {
var ts = Math.floor(Utilities.parseDate(str, tz, 'yyyy-MM-dd HH:mm:ss').getTime() / 1000);
if (ts > 0) { actualLastRow = i + 1; lastTs = ts; break; }
} else {
actualLastRow = i + 1;
break;
}
}
}
}
// Build the Last.fm API request (fetches up to 200 new plays at a time)
var url = 'https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks'
+ '&user=' + encodeURIComponent(cfg.user) + '&api_key=' + cfg.apiKey
+ '&format=json&from=' + (lastTs + 1) + '&limit=200';
// Fetch with up to 5 retries in case of rate limiting or network errors
var response, attempt = 0;
while (attempt < 5) {
try {
response = UrlFetchApp.fetch(url, { muteHttpExceptions: true,
headers: { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } });
if (response.getResponseCode() === 429 || response.getResponseCode() === 403)
throw new Error('Rate limited');
break;
} catch(e) {
if (++attempt >= 5) {
// Give up for now, try again in 90 minutes
ScriptApp.newTrigger('fetchAndLogLastFmHistory').timeBased().after(90*60*1000).create();
return;
}
Utilities.sleep(Math.pow(2, attempt) * 1000 + Math.floor(Math.random() * 1000));
}
}
var data = JSON.parse(response.getContentText());
if (data.error) {
Logger.log('Last.fm API error: ' + data.message);
ScriptApp.newTrigger('fetchAndLogLastFmHistory').timeBased().after(90*60*1000).create();
return;
}
// Write new tracks to the sheet (skip the "now playing" entry which has no timestamp)
var tracks = data.recenttracks && data.recenttracks.track;
if (tracks && tracks.length) {
var done = tracks.filter(function(t) { return t.date && +t.date.uts > 0; });
if (done.length) {
done.sort(function(a, b) { return +a.date.uts - +b.date.uts; }); // oldest first
var rows = done.map(function(t) {
return [
t.name,
t.artist['#text'],
t.album['#text'],
Utilities.formatDate(new Date(+t.date.uts * 1000), tz, 'yyyy-MM-dd HH:mm:ss')
];
});
sheet.getRange(actualLastRow + 1, 1, rows.length, 4).setValues(rows);
Logger.log('Added ' + rows.length + ' new track(s).');
}
} else {
Logger.log('No new tracks since last sync.');
}
// Schedule the next sync in 90 minutes
ScriptApp.newTrigger('fetchAndLogLastFmHistory').timeBased().after(90*60*1000).create();
}
// Run this once from Last.fm → Start Auto-Update to kick off the 90-minute cycle.
function setupLastFmTrigger() {
// Check the credentials here, in front of whoever just clicked the menu item,
// rather than letting the first timed run fail 60 seconds later where nobody
// is looking. Also creates the Settings tab if this sheet hasn't got one.
var cfg = getSettings_();
if (!cfg.user || !cfg.apiKey) {
throw new Error('Fill in B1 (your Last.fm username) and B2 (your API key) in the Settings tab first, then start Auto-Update again.');
}
ScriptApp.getProjectTriggers().forEach(function(t) {
if (t.getHandlerFunction() === 'fetchAndLogLastFmHistory') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('fetchAndLogLastFmHistory').timeBased().after(60*1000).create();
Logger.log('All set! First sync in 1 minute, then every 90 minutes automatically.');
}
// Adds the "Last.fm" menu to your sheet when you open it.
function onOpen() {
SpreadsheetApp.getUi().createMenu('Last.fm')
.addItem('Fetch Latest Tracks', 'fetchAndLogLastFmHistory')
.addItem('Start Auto-Update (90 min)', 'setupLastFmTrigger')
.addSeparator()
.addItem('Start Genre Fetcher', 'setupGenreFetcherTrigger')
.addItem('Stop Genre Fetcher', 'deleteGenreTriggers')
.addToUi();
}
// =============================================================
// MANUAL ENTRY (Web App)
// This function receives a play from dankcharts.fm and writes
// it to your sheet. Only needed if you want the "Add Play"
// button — requires deploying this script as a Web App.
// =============================================================
function doPost(e) {
try {
var data = JSON.parse(e.postData.contents);
// Handle rules early — no sheet access needed
if (data.action === 'saveRules') {
PropertiesService.getDocumentProperties().setProperty('dc_autocorrect_rules', data.rules || '[]');
return ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
.setMimeType(ContentService.MimeType.JSON);
}
if (data.action === 'loadRules') {
var savedRules = PropertiesService.getDocumentProperties().getProperty('dc_autocorrect_rules') || '[]';
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', rules: savedRules }))
.setMimeType(ContentService.MimeType.JSON);
}
var ss = SpreadsheetApp.getActiveSpreadsheet();
var cfg = ss.getSheetByName('Settings');
var tabName = cfg ? cfg.getRange('B3').getValue().toString().trim() : '';
var sheet = ss.getSheetByName(tabName || 'Full Raw Listening History') || ss.getSheets()[0];
// Detect which column is which by reading the header row
var lastCol = Math.max(sheet.getLastColumn(), 1);
var headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]
.map(function(h) { return h.toString().toLowerCase().trim(); });
var aliases = {
title: ['song title', 'title', 'track', 'track name', 'song name'],
artist: ['artist', 'artist name', 'performer'],
album: ['album', 'album name', 'release'],
datetime: ['date and time', 'date', 'datetime', 'timestamp', 'time', 'played at', 'scrobble time']
};
var colIdx = {};
for (var key in aliases) {
for (var i = 0; i < aliases[key].length; i++) {
var idx = headers.indexOf(aliases[key][i]);
if (idx !== -1) { colIdx[key] = idx; break; }
}
}
var tz = ss.getSpreadsheetTimeZone();
// Correct multiple specific rows by timestamp — one sheet read, individual row writes
if (data.action === 'bulkUpdate') {
var updates = data.updates || [];
if (!updates.length)
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: 0 }))
.setMimeType(ContentService.MimeType.JSON);
var corrMap = {};
updates.forEach(function(u) {
if (!u.originalTimestamp || u.originalTimestamp <= 0) return;
corrMap[Utilities.formatDate(new Date(u.originalTimestamp * 1000), tz, 'yyyy-MM-dd HH:mm:ss')] = u;
});
var lastRow = sheet.getLastRow();
var changed = [];
if (lastRow >= 2) {
var values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
for (var r = 0; r < values.length; r++) {
var cell = values[r][colIdx.datetime];
var cellStr = cell instanceof Date
? Utilities.formatDate(cell, tz, 'yyyy-MM-dd HH:mm:ss')
: cell.toString().trim().slice(0, 19);
var corr = corrMap[cellStr];
if (corr) {
if (colIdx.title !== undefined) values[r][colIdx.title] = corr.track || '';
if (colIdx.artist !== undefined) values[r][colIdx.artist] = corr.artist || '';
if (colIdx.album !== undefined) values[r][colIdx.album] = corr.album || '';
changed.push({ index: r, row: values[r] });
}
}
changed.forEach(function(item) {
sheet.getRange(item.index + 2, 1, 1, lastCol).setValues([item.row]);
});
}
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: changed.length }))
.setMimeType(ContentService.MimeType.JSON);
}
// Apply multiple autocorrect rules in one sheet read/write pass
if (data.action === 'applyRules') {
var ruleMap = data.rules || {};
var ruleKeys = Object.keys(ruleMap);
if (!ruleKeys.length)
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: 0 }))
.setMimeType(ContentService.MimeType.JSON);
var count = 0;
var lastRow2 = sheet.getLastRow();
if (lastRow2 >= 2) {
var vals = sheet.getRange(2, 1, lastRow2 - 1, lastCol).getValues();
var changedRows = [];
for (var r = 0; r < vals.length; r++) {
var rowArtist = colIdx.artist !== undefined ? vals[r][colIdx.artist].toString().trim() : '';
var rowTitle = colIdx.title !== undefined ? vals[r][colIdx.title].toString().trim() : '';
var rowAlbum = colIdx.album !== undefined ? vals[r][colIdx.album].toString().trim() : '';
var rule = ruleMap[rowArtist + '|' + rowTitle + '|' + rowAlbum];
if (rule) {
if (colIdx.title !== undefined) vals[r][colIdx.title] = rule.track || '';
if (colIdx.artist !== undefined) vals[r][colIdx.artist] = rule.artist || '';
if (colIdx.album !== undefined) vals[r][colIdx.album] = rule.album || '';
changedRows.push(r);
count++;
}
}
if (changedRows.length > 0) {
var gs = changedRows[0], ge = changedRows[0];
for (var ci = 1; ci < changedRows.length; ci++) {
if (changedRows[ci] === ge + 1) {
ge = changedRows[ci];
} else {
sheet.getRange(gs + 2, 1, ge - gs + 1, lastCol).setValues(vals.slice(gs, ge + 1));
gs = ge = changedRows[ci];
}
}
sheet.getRange(gs + 2, 1, ge - gs + 1, lastCol).setValues(vals.slice(gs, ge + 1));
}
}
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: count }))
.setMimeType(ContentService.MimeType.JSON);
}
// Edit an existing row — jump directly to the row using the hint from the client,
// fall back to a full scan only if the hint is stale (rows added/deleted since last sync).
if (data.action === 'update') {
if (!data.timestamp || data.timestamp <= 0)
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: 'Invalid new timestamp' }))
.setMimeType(ContentService.MimeType.JSON);
var origFmt = Utilities.formatDate(new Date(data.originalTimestamp * 1000), tz, 'yyyy-MM-dd HH:mm:ss');
var newFmt = Utilities.formatDate(new Date(data.timestamp * 1000), tz, 'yyyy-MM-dd HH:mm:ss');
var lastRow = sheet.getLastRow();
if (lastRow < 2) throw new Error('Sheet is empty');
var targetRow = -1;
var targetValues = null;
// Fast path: client sends rowNumber — read just that one row instead of the whole sheet
if (data.rowNumber && data.rowNumber >= 2 && data.rowNumber <= lastRow) {
var hint = sheet.getRange(data.rowNumber, 1, 1, lastCol).getValues()[0];
var hintCell = hint[colIdx.datetime];
var hintStr = hintCell instanceof Date
? Utilities.formatDate(hintCell, tz, 'yyyy-MM-dd HH:mm:ss')
: hintCell.toString().trim().slice(0, 19);
if (hintStr === origFmt) { targetRow = data.rowNumber; targetValues = hint; }
}
// Fallback: linear scan (used when rows have shifted since last sync)
if (targetRow === -1) {
var values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
for (var r = 0; r < values.length; r++) {
var cell = values[r][colIdx.datetime];
var cellStr = cell instanceof Date
? Utilities.formatDate(cell, tz, 'yyyy-MM-dd HH:mm:ss')
: cell.toString().trim().slice(0, 19);
if (cellStr === origFmt) { targetRow = r + 2; targetValues = values[r]; break; }
}
}
if (targetRow === -1) throw new Error('Row not found for timestamp: ' + origFmt);
if (colIdx.title !== undefined) targetValues[colIdx.title] = data.track || '';
if (colIdx.artist !== undefined) targetValues[colIdx.artist] = data.artist || '';
if (colIdx.album !== undefined) targetValues[colIdx.album] = data.album || '';
if (colIdx.datetime !== undefined) targetValues[colIdx.datetime] = newFmt;
sheet.getRange(targetRow, 1, 1, lastCol).setValues([targetValues]);
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: true }))
.setMimeType(ContentService.MimeType.JSON);
}
// Update all rows matching artist + title + album combination
if (data.action === 'batchUpdate') {
var count = 0;
var lastRow2 = sheet.getLastRow();
if (lastRow2 >= 2) {
var vals = sheet.getRange(2, 1, lastRow2 - 1, lastCol).getValues();
var matchedRows = [];
for (var r = 0; r < vals.length; r++) {
var rowArtist = colIdx.artist !== undefined ? vals[r][colIdx.artist].toString().trim() : '';
var rowTitle = colIdx.title !== undefined ? vals[r][colIdx.title].toString().trim() : '';
var rowAlbum = colIdx.album !== undefined ? vals[r][colIdx.album].toString().trim() : '';
if (rowArtist === data.matchArtist && rowTitle === data.matchTitle && rowAlbum === data.matchAlbum) {
matchedRows.push(r + 2);
count++;
}
}
if (count > 0) {
var colLetter = function(n) { var s = ''; while (n > 0) { var m = (n-1) % 26; s = String.fromCharCode(65+m) + s; n = Math.floor((n-1) / 26); } return s; };
if (colIdx.artist !== undefined) sheet.getRangeList(matchedRows.map(function(r) { return colLetter(colIdx.artist + 1) + r; })).setValue(data.artist || '');
if (colIdx.title !== undefined) sheet.getRangeList(matchedRows.map(function(r) { return colLetter(colIdx.title + 1) + r; })).setValue(data.track || '');
if (colIdx.album !== undefined) sheet.getRangeList(matchedRows.map(function(r) { return colLetter(colIdx.album + 1) + r; })).setValue(data.album || '');
}
}
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: count }))
.setMimeType(ContentService.MimeType.JSON);
}
// Add a new row
if (!data.timestamp || data.timestamp <= 0)
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: 'Invalid timestamp' }))
.setMimeType(ContentService.MimeType.JSON);
var fmt = Utilities.formatDate(new Date(data.timestamp * 1000), tz, 'yyyy-MM-dd HH:mm:ss');
var row = new Array(lastCol).fill('');
if (colIdx.title !== undefined) row[colIdx.title] = data.track || '';
if (colIdx.artist !== undefined) row[colIdx.artist] = data.artist || '';
if (colIdx.album !== undefined) row[colIdx.album] = data.album || '';
if (colIdx.datetime !== undefined) row[colIdx.datetime] = fmt;
sheet.appendRow(row);
return ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
.setMimeType(ContentService.MimeType.JSON);
} catch(err) {
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: err.message }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// =============================================================
// BULK GENRE FETCHER
// Fills columns E–I (Genre 1–5) by calling Last.fm's
// track.gettoptags API for every row that has no genre yet.
// Runs in batches of 50 rows every 5 minutes so it never
// hits the 6-minute Apps Script execution limit.
// Start it from Last.fm → Start Genre Fetcher in the sheet menu.
// =============================================================
function fetchGenresBatch() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var settingsSheet = ss.getSheetByName('Settings');
if (!settingsSheet) return;
var apiKey = settingsSheet.getRange('B2').getValue();
var dataTabName = settingsSheet.getRange('B3').getValue();
var dataSheet = ss.getSheetByName(dataTabName);
if (!apiKey || !dataSheet) return;
var props = PropertiesService.getScriptProperties();
var startRow = parseInt(props.getProperty('lastProcessedRow')) || 2;
var lastRow = dataSheet.getLastRow();
if (startRow > lastRow) {
deleteGenreTriggers();
Logger.log('Genre fetcher complete — all rows processed.');
return;
}
var BATCH_SIZE = 50;
var endRow = Math.min(startRow + BATCH_SIZE - 1, lastRow);
var data = dataSheet.getRange(startRow, 1, endRow - startRow + 1, 9).getValues();
var updates = [];
for (var i = 0; i < data.length; i++) {
var row = data[i];
var track = row[0];
var artist = row[1];
var genre1 = row[4];
if (track && artist && genre1 === '') {
var genres = getGenresFromLastFm(artist, track, apiKey);
var genresToUpdate = ['', '', '', '', ''];
for (var g = 0; g < Math.min(genres.length, 5); g++) {
genresToUpdate[g] = genres[g];
}
updates.push(genresToUpdate);
} else {
updates.push([row[4], row[5], row[6], row[7], row[8]]);
}
}
dataSheet.getRange(startRow, 5, updates.length, 5).setValues(updates);
props.setProperty('lastProcessedRow', endRow + 1);
Logger.log('Genre fetcher: processed rows ' + startRow + ' to ' + endRow + '.');
}
function getGenresFromLastFm(artist, track, apiKey) {
var url = 'https://ws.audioscrobbler.com/2.0/?method=track.gettoptags'
+ '&artist=' + encodeURIComponent(artist)
+ '&track=' + encodeURIComponent(track)
+ '&api_key=' + apiKey + '&format=json';
try {
var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (response.getResponseCode() === 200) {
var json = JSON.parse(response.getContentText());
if (json.toptags && json.toptags.tag) {
return json.toptags.tag.map(function(t) { return t.name; });
}
}
} catch(e) {}
return [];
}
function setupGenreFetcherTrigger() {
deleteGenreTriggers();
ScriptApp.newTrigger('fetchGenresBatch').timeBased().everyMinutes(5).create();
PropertiesService.getScriptProperties().setProperty('lastProcessedRow', '2');
SpreadsheetApp.getUi().alert('Genre fetcher started! It will fill columns E–I every 5 minutes.');
}
function deleteGenreTriggers() {
var triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === 'fetchGenresBatch') {
ScriptApp.deleteTrigger(triggers[i]);
}
}
}
2 · Fill in the tab it makes for you
Run step 2 below once. The script adds a labelled Settings tab itself, then stops and asks you to fill in these cells. Do that and run it again.
B1your Last.fm username
B2your Last.fm API key
B3data tab name (optional)
Leave B3 empty and it writes to a tab called Full Raw Listening History. That tab needs its columns in this order: Song Title · Artist · Album · Date and Time.
Auto-sync
In your sheet: Last.fm → Start Auto-Update (90 min)
Last.fm is a menu in your sheet's own menu bar, just right of Help. Don't see it? Reload the sheet, since that menu is added each time the sheet opens.
The first run asks you to approve the script
Authorization requiredContinue
Choose an accountyour own Google account
Google hasn't verified this appAdvanced → Go to … (unsafe)
… wants access to your Google AccountAllow
"Unsafe" is what Google says about every personal script. This one is your copy in your Drive, and it only ever touches this one sheet.
First plays land about a minute later, then a fresh batch every 90 minutes.
Add Play
In the Apps Script editor: Deploy → New deployment
Open the editor from your sheet with Extensions → Apps Script; Deploy sits at its top right. Set Execute as: Me · Access: Anyone, then press Deploy. Google asks you to approve it again, the same four screens as above.
Type isn't a dropdown. Click the small gear beside "Select type" and pick Web app — it's the part everyone hunts for.
Add Play
Copy the Web app URL it finishes on
The dialog ends on a long URL ending in /exec, with a Copy button beside it. Paste it into the field just below.
Only needed for Add Play. Auto-sync runs without it.