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
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.