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.
🎵
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 more
The sheet must be shared as "Anyone with the link can view"
Enable auto-sync & manual play entry (optional)
Paste your deployed Apps Script URL here to enable the "Add Play" button.
How to set this up
If using our template, fill in your details in the Settings tab (B1 = username, B2 = API key)
Open your sheet → Extensions → Apps Script
Replace any existing code with the full script below and save (Ctrl+S)
For auto-sync: click Last.fm → Start Auto-Update (90 min) in your sheet's menu bar
For manual entry: click Deploy → New deployment → Type: Web app · Execute as: Me · Access: Anyone → copy the URL and paste it above
Authorization warning: Google will show a "This app isn't verified" screen — this 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 sheet.
// =============================================================
// 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 s = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Settings');
if (!s) throw new Error('"Settings" tab not found. Check your sheet setup.');
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'
};
}
// =============================================================
// 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();
// 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 !== '') {
actualLastRow = i + 1;
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))
lastTs = Math.floor(Utilities.parseDate(str, tz, 'yyyy-MM-dd HH:mm:ss').getTime() / 1000);
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; });
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 setupTrigger() {
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)', 'setupTrigger')
.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);
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();
// Edit an existing row by matching its original timestamp
if (data.action === 'update') {
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 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) {
if (colIdx.title !== undefined) sheet.getRange(r + 2, colIdx.title + 1).setValue(data.track || '');
if (colIdx.artist !== undefined) sheet.getRange(r + 2, colIdx.artist + 1).setValue(data.artist || '');
if (colIdx.album !== undefined) sheet.getRange(r + 2, colIdx.album + 1).setValue(data.album || '');
if (colIdx.datetime !== undefined) sheet.getRange(r + 2, colIdx.datetime + 1).setValue(newFmt);
return ContentService.createTextOutput(JSON.stringify({ status: 'ok', updated: true }))
.setMimeType(ContentService.MimeType.JSON);
}
}
throw new Error('Row not found for timestamp: ' + origFmt);
}
// Add a new row
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);
}
}
Your full listening history will be loaded directly from Last.fm
Scrobble Setup
Not connected
Connect to enable manual scrobbling from this app.
Use custom API credentials
Upload your listening history. Supported formats: Last.fm CSV, mycharts CSV, Spotify ZIP (extended streaming history), Deezer XLSX.
📂
Drop file here or click to browse
.csv · .zip · .xlsx · .xls
Certification Thresholds (Plays)
Albums
Songs
Events Calendar
Number of top all-time artists to fetch birthdays & anniversaries for