Import accounts from home page

This commit is contained in:
Cadence Ember 2025-04-03 16:39:46 +13:00
parent cba0552ed0
commit cf6310b89a
12 changed files with 222 additions and 62 deletions

View file

@ -10,8 +10,9 @@ block view
p.fs-body3.mb8= item.item_title p.fs-body3.mb8= item.item_title
.d-flex.fw-wrap.g4 .d-flex.fw-wrap.g4
if item.why if item.why
a.s-tag.s-tag__xs(title=item.why).fc-orange-400(href=and({filter_field: "why", filter: "reviewed"})) a.s-tag.s-tag__xs.fc-orange-400(title=item.why href=and({filter_field: "why", filter: "reviewed"}))
!= icons.get("star-solid", 16) != icons.get("star-solid", 16)
if hasFullTrackData
a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "item_id", filter: item.item_id, filter_fuzzy: null})) a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "item_id", filter: item.item_id, filter_fuzzy: null}))
span.s-tag--sponsor!= icons.get("music-note", 16) span.s-tag--sponsor!= icons.get("music-note", 16)
= item.track_count = item.track_count

View file

@ -11,6 +11,7 @@ block view
a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null})) a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null}))
span.s-tag--sponsor!= icons.get("album", 16) span.s-tag--sponsor!= icons.get("album", 16)
= item.album_count = item.album_count
if hasFullTrackData
a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null})) a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null}))
span.s-tag--sponsor!= icons.get("music-note", 16) span.s-tag--sponsor!= icons.get("music-note", 16)
= item.track_count = item.track_count

View file

@ -0,0 +1,6 @@
.s-notice.s-notice__success
.d-flex.ai-center.gx16
!= icons.get("check-circle")
.fl-grow1 Imported #{storedItemCount}/#{count} purchases and #{storedTrackCount} tracks.
.mt16
a.s-link(href=`/${account}/`) Check it out

View file

@ -5,6 +5,8 @@ html
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
title BC Explorer title BC Explorer
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
script(src="/static/htmx.js")
meta(name="htmx-config" content='{"requestClass":"is-loading"}')
body.themed.theme-system.overflow-y-scroll body.themed.theme-system.overflow-y-scroll
header.s-topbar.ps-sticky.t0 header.s-topbar.ps-sticky.t0
.s-topbar--container.wmx9 .s-topbar--container.wmx9
@ -12,9 +14,18 @@ html
!= icons.get("compass-solid", 24) != icons.get("compass-solid", 24)
.ml4 BC Explorer .ml4 BC Explorer
.mx-auto.wmx9.py24.px16.g24.s-prose .mx-auto.wmx9.py24.px16.g24
.s-prose
h1 Select profile h1 Select profile
- const names = select("account", "account").pluck().all() - const names = select("account", "account").pluck().all()
ul ul
each name in names each name in names
li: a(href=`/${name}/`)= name li: a(href=`/${name}/`)= name
form.mt32(hx-post="/api/load-collection" hx-target="#results" hx-indicator="#submit-username")
h2 Add your profile
.d-flex.gy4.fd-column.ps-relative
label.s-label(for="username") Bandcamp username
input.s-input.wmx3#username(name="account" placeholder="Enter your Bandcamp username here")
button.s-btn.s-btn__filled.my16#submit-username Load collection
#results.d-flex

View file

@ -28,6 +28,13 @@ html
.s-navigation--item.is-loading svg, .s-tag.is-loading svg, .s-sidebarwidget.is-loading svg { .s-navigation--item.is-loading svg, .s-tag.is-loading svg, .s-sidebarwidget.is-loading svg {
visibility: hidden; visibility: hidden;
} }
.s-btn__icon.is-loading {
--_li-offset: 0.7em;
--_il-size: 1.5em;
}
.s-btn__icon.is-loading svg {
display: none;
}
.s-navigation__toggle.s-navigation { .s-navigation__toggle.s-navigation {
--_na-item-bg: var(--black-150); --_na-item-bg: var(--black-150);
} }
@ -98,7 +105,7 @@ html
strong= filter strong= filter
a.s-btn.s-notice--btn(href=and({filter: null, filter_field: null, filter_fuzzy: null})) Clear a.s-btn.s-notice--btn(href=and({filter: null, filter_field: null, filter_fuzzy: null})) Clear
else else
form.d-flex.ai-stretch.gx8.jc-space-between.baw0 form.d-flex.ai-stretch.gx8.jc-space-between.baw0(hx-indicator="#search-submit")
input.s-input(name="filter" placeholder="Search" autocomplete="off").fl-grow1 input.s-input(name="filter" placeholder="Search" autocomplete="off").fl-grow1
input(type="hidden" name="filter_field" value= input(type="hidden" name="filter_field" value=
( arrange === "artist" ? "band_name" ( arrange === "artist" ? "band_name"
@ -108,11 +115,10 @@ html
input(type="hidden" name="filter_fuzzy" value="true") input(type="hidden" name="filter_fuzzy" value="true")
input(type="hidden" name="arrange" value=arrange) input(type="hidden" name="arrange" value=arrange)
input(type="hidden" name="shape" value=shape) input(type="hidden" name="shape" value=shape)
button.s-btn.s-btn__xs.s-btn__outlined.s-btn__muted!= icons.get("search") button.s-btn.s-btn__xs.s-btn__icon.s-btn__outlined.s-btn__muted#search-submit(style="height: 38px")!= icons.get("search")
.s-sidebarwidget.wmn3 .s-sidebarwidget.wmn3
.s-sidebarwidget--header Collection .s-sidebarwidget--header Collection
a.s-sidebarwidget--action Refresh
table.s-sidebarwidget--content.s-sidebarwidget__items table.s-sidebarwidget--content.s-sidebarwidget__items
tr.s-sidebarwidget--item tr.s-sidebarwidget--item
th items th items

View file

@ -1,5 +1,5 @@
if downloader.total > 0 if downloader.total > 0
.d-flex.jc-center#tag-download #tag-download
if !downloader.running && !downloader.outcome if !downloader.running && !downloader.outcome
form.s-notice.s-notice__info.d-flex.ai-center.p8.gx16.pl16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-post="/api/tag-download") form.s-notice.s-notice__info.d-flex.ai-center.p8.gx16.pl16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-post="/api/tag-download")
!= icons.get("info-circle") != icons.get("info-circle")
@ -8,15 +8,17 @@ if downloader.total > 0
button.s-btn.s-btn__outlined Download now button.s-btn.s-btn__outlined Download now
else if !downloader.outcome else if !downloader.outcome
.s-notice.p16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-get=`/api/tag-download?account=${account}` hx-trigger="every 5s") .s-notice.p16(role="status" hx-swap="outerHTML" hx-target="#tag-download" hx-get=`/api/tag-download?account=${account}` hx-trigger="every 5s" hx-indicator="null")
.d-flex.gx16.ai-center .d-flex.gx16.ai-center
!= icons.get("cloud-download") != icons.get("cloud-download")
div Downloading tags... .fl-grow1 Downloading tags...
#tag-status-indicator
p.mt12 You can keep using BC Explorer while this continues in the background. p.mt12 You can keep using BC Explorer while this continues in the background.
- let percentage = `${Math.round(downloader.processed/downloader.total*100)}%`
.s-progress.mt16 .s-progress.mt16
.s-progress--bar(style=`width: ${downloader.processed/downloader.total*100}%`) .s-progress--bar(style=`width: ${percentage}`)
.d-flex.jc-space-between.fs-fine .d-flex.jc-space-between.fs-fine
span= downloader.processed span= percentage
span= downloader.total span= downloader.total
else if downloader.outcome === "Success" else if downloader.outcome === "Success"

View file

@ -16,6 +16,7 @@ block view
a.s-tag.s-tag__xs(href=and({arrange: "artist", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null})) a.s-tag.s-tag__xs(href=and({arrange: "artist", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null}))
span.s-tag--sponsor!= icons.get("people-tag", 16) span.s-tag--sponsor!= icons.get("people-tag", 16)
= item.artist_count = item.artist_count
if hasFullTrackData
a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null})) a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null}))
span.s-tag--sponsor!= icons.get("music-note", 16) span.s-tag--sponsor!= icons.get("music-note", 16)
= item.track_count = item.track_count

View file

@ -10,14 +10,19 @@ but the idea is you can more easily search your whole collection and play it str
## how to use ## how to use
1. npm install
2. node start.js
3. http://localhost:2239
## import more reliable statistics
the default data is pretty close, but you can do this to get the exact data
1. log into bandcamp in your browser 1. log into bandcamp in your browser
2. in the top right, click the button to view your collection, should take you to a url like https://bandcamp.com/cloudrac3r 2. in the top right, click the button to view your collection, should take you to a url like https://bandcamp.com/cloudrac3r
3. open devtools and reload the page 3. open devtools and reload the page
4. go to network tab and save all as har, save as scripts/account.har 4. go to network tab and save all as har, save as scripts/account.har
5. npm install
6. node scripts/populate-albums-tracks.js 6. node scripts/populate-albums-tracks.js
8. node start.js
9. http://localhost:2239
## license ## license

View file

@ -11,14 +11,15 @@ const loadTags = sync.require("./load-tags")
const displayCurrency = "NZD" const displayCurrency = "NZD"
const displayCurrencySymbol = "$" const displayCurrencySymbol = "$"
const currencyExchange = new Map([ const currencyExchange = new Map([
["USD", 1], ["AUD", 0.63],
["JPY", 0.0067], ["CAD", 0.7],
["NZD", 0.57], ["CHF", 1.13],
["EUR", 1.08], ["EUR", 1.08],
["GBP", 1.3], ["GBP", 1.3],
["CAD", 0.7], ["JPY", 0.0067],
["NOK", 0.1], ["NOK", 0.1],
["CHF", 1.13] ["NZD", 0.57],
["USD", 1],
]) ])
const sqls = { const sqls = {
@ -136,23 +137,23 @@ router.get("/:account/", defineEventHandler({
account, account,
query, query,
count: { count: {
total: db.prepare("SELECT count(*) FROM item").pluck().get(), total: db.prepare("SELECT count(*) FROM item WHERE account = ?").pluck().get(account),
runtime: db.prepare("SELECT iif(sum(duration) > 86400, cast(total(duration)/86400 AS INTEGER) || 'd ' || cast(total(duration)/3600%24 AS INTEGER) || 'h', cast(total(duration)/3600 AS INTEGER) || 'h') FROM track").pluck().get(), runtime: db.prepare("SELECT iif(sum(duration) > 86400, cast(total(duration)/86400 AS INTEGER) || 'd ' || cast(total(duration)/3600%24 AS INTEGER) || 'h', cast(total(duration)/3600 AS INTEGER) || 'h') FROM track WHERE account = ?").pluck().get(account),
albums: db.prepare("SELECT count(*) FROM item WHERE item_type = 'album'").pluck().get(), albums: db.prepare("SELECT count(*) FROM item WHERE item_type = 'album' AND account = ?").pluck().get(account),
singles: db.prepare("SELECT count(*) FROM item WHERE item_type = 'track'").pluck().get(), singles: db.prepare("SELECT count(*) FROM item WHERE item_type = 'track' AND account = ?").pluck().get(account),
free: db.prepare("SELECT count(*) FROM item WHERE price = 0").pluck().get(), free: db.prepare("SELECT count(*) FROM item WHERE price = 0 AND account = ?").pluck().get(account),
paid: db.prepare("SELECT count(*) FROM item WHERE price > 0").pluck().get(), paid: db.prepare("SELECT count(*) FROM item WHERE price > 0 AND account = ?").pluck().get(account),
tracks: db.prepare("SELECT count(*) FROM track").pluck().get(), tracks: db.prepare("SELECT count(*) FROM track WHERE account = ?").pluck().get(account),
avgTracks: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM track INNER JOIN item USING (item_id) WHERE item_type = 'album' GROUP BY item_id)").pluck().get()*10)/10, avgTracks: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM track INNER JOIN item USING (account, item_id) WHERE item_type = 'album' AND account = ? GROUP BY item_id)").pluck().get(account)*10)/10,
tags: db.prepare("SELECT count(*) FROM item_tag").pluck().get(), tags: db.prepare("SELECT count(*) FROM item_tag WHERE account = ?").pluck().get(account),
avgTags: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM item_tag GROUP BY item_id)").pluck().get()*10)/10, avgTags: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM item_tag WHERE account = ? GROUP BY item_id)").pluck().get(account)*10)/10,
lonelyTags: db.prepare("SELECT count(*) FROM (SELECT tag FROM item_tag GROUP BY tag HAVING count(*) = 1)").pluck().get(), lonelyTags: db.prepare("SELECT count(*) FROM (SELECT tag FROM item_tag WHERE account = ? GROUP BY tag HAVING count(*) = 1)").pluck().get(account),
value: Math.round(select("item", ["currency", "price"]).all().map(c => { value: Math.round(select("item", ["currency", "price"], {account}).all().map(c => {
return (currencyExchange.get(c.currency) || 0.6) * c.price / (currencyExchange.get(displayCurrency) || 1) / 10 return (currencyExchange.get(c.currency) || 0.6) * c.price / (currencyExchange.get(displayCurrency) || 1) / 10
}).reduce((a, c) => a + c, 0)) * 10, }).reduce((a, c) => a + c, 0)) * 10,
displayCurrency, displayCurrency,
displayCurrencySymbol, displayCurrencySymbol,
taste: db.prepare("with popularity (a) as (select avg(also_collected_count) from item group by band_url) select sum(iif(a >= 0 and a < 20, 1, 0)) as cold, sum(iif(a >= 20 and a < 200, 1, 0)) as warm, sum(iif(a >= 200 and a < 2000, 1, 0)) as hot, sum(iif(a >= 2000, 1, 0)) as supernova from popularity").raw().get() taste: db.prepare("with popularity (a) as (select avg(also_collected_count) from item WHERE account = ? group by band_url) select sum(iif(a >= 0 and a < 20, 1, 0)) as cold, sum(iif(a >= 20 and a < 200, 1, 0)) as warm, sum(iif(a >= 200 and a < 2000, 1, 0)) as hot, sum(iif(a >= 2000, 1, 0)) as supernova from popularity").raw().get(account)
} }
} }
if (mode === "artist_grid") { if (mode === "artist_grid") {
@ -163,6 +164,7 @@ router.get("/:account/", defineEventHandler({
locals.downloadManager = loadTags.downloadManager locals.downloadManager = loadTags.downloadManager
locals.downloader = loadTags.downloadManager.check(account) locals.downloader = loadTags.downloadManager.check(account)
} }
locals.hasFullTrackData = locals.count.tracks > locals.count.total
return pugSync.render(event, `${arrange}_${shape}.pug`, locals) return pugSync.render(event, `${arrange}_${shape}.pug`, locals)
} }
})) }))

93
routes/load-collection.js Normal file
View file

@ -0,0 +1,93 @@
// @ts-check
const assert = require("assert/strict")
const fs = require("fs")
const sqlite = require("better-sqlite3")
const domino = require("domino")
const {defineEventHandler, readValidatedBody} = require("h3")
const {z} = require("zod")
const {sync, db, router} = require("../passthrough")
const pugSync = sync.require("../pug-sync")
async function loadCollection(inputUsername) {
assert.match(inputUsername, /^[a-z0-9_-]+$/)
const html = await fetch(`https://bandcamp.com/${inputUsername}`).then(res => res.text())
const doc = domino.createDocument(html)
const first = doc.querySelector(".collection-item-container")
assert(first)
const token = first.getAttribute("data-token")
assert(token)
const tokenParts = token.split(":")
tokenParts[0] = String(+tokenParts[0] + 1) // ensure we get the first item
const customToken = tokenParts.join(":")
const pagedataText = doc.querySelector("#pagedata")?.getAttribute("data-blob")
assert(pagedataText)
const pagedata = JSON.parse(pagedataText)
const fan_id = pagedata.fan_data.fan_id
const account = pagedata.fan_data.username
const count = pagedata.collection_data.item_count
const items = await fetch("https://bandcamp.com/api/fancollection/1/collection_items", {
method: "POST",
body: JSON.stringify({
fan_id,
older_than_token: customToken,
count
})
}).then(res => res.json())
db.prepare("INSERT OR IGNORE INTO account (account, fan_id) VALUES (?, ?)").run(account, fan_id)
const columns = ["account", "item_id", "item_type", "band_id", "added", "updated", "purchased", "featured_track", "why", "also_collected_count", "item_title", "item_url", "item_art_url", "band_name", "band_url", "featured_track_title", "featured_track_number", "featured_track_duration", "album_id", "album_title", "price", "currency", "label", "label_id"]
const upsert_columns = ["added", "updated", "purchased"]
const preparedItem = db.prepare(`INSERT INTO item (${columns.join(", ")}) VALUES (${columns.map(x => "@" + x).join(", ")}) ON CONFLICT DO UPDATE SET ${upsert_columns.map(x => `${x} = @${x}`).join(", ")}`)
db.transaction(() => {
for (const item of items.items) {
preparedItem.run({
account,
...item,
purchased: new Date(item.purchased).getTime(),
added: new Date(item.added).getTime(),
updated: new Date(item.updated).getTime()
})
}
})()
const storedItemCount = db.prepare("SELECT count(*) AS count FROM item WHERE account = ?").pluck().get(account)
const preparedTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration) VALUES (@account, @item_id, @track_id, @title, @artist, @track_number, @duration)")
db.transaction(() => {
for (const [key, tracklist] of Object.entries(items.tracklists)) {
assert.match(key[0], /[at]/)
for (const track of tracklist) {
preparedTrack.run({
account,
item_id: key.slice(1),
track_id: track.id,
...track
})
}
}
})()
const storedTrackCount = db.prepare("SELECT count(*) AS count FROM track WHERE account = ?").pluck().get(account)
return {
storedItemCount,
storedTrackCount,
count,
account
}
}
const schema = z.object({
account: z.string()
})
router.post("/api/load-collection", defineEventHandler(async event => {
const {account} = await readValidatedBody(event, schema.parse)
const result = await loadCollection(account)
return pugSync.render(event, "collection-loaded.pug", result)
}))

View file

@ -7,14 +7,15 @@ const {getValidatedQuery, readValidatedBody, defineEventHandler} = require("h3")
const {sync, db, from, router} = require("../passthrough") const {sync, db, from, router} = require("../passthrough")
const pugSync = sync.require("../pug-sync") const pugSync = sync.require("../pug-sync")
const i = db.prepare("INSERT OR IGNORE INTO item_tag (account, item_id, tag) VALUES (?, ?, ?)") const insertTag = db.prepare("INSERT OR IGNORE INTO item_tag (account, item_id, tag) VALUES (?, ?, ?)")
const insertTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration) VALUES (?, ?, ?, ?, ?, ?, ?)")
class TagDownloader extends sync.reloadClassMethods(() => TagDownloader) { class TagDownloader extends sync.reloadClassMethods(() => TagDownloader) {
constructor(account) { constructor(account) {
super() super()
this.account = account this.account = account
this.processed = 0 this.processed = 0
this.untaggedItems = db.prepare("SELECT account, item_id, item_title, item_url FROM item LEFT JOIN item_tag USING (account, item_id) WHERE account = ? AND tag IS NULL").all(account) this.untaggedItems = db.prepare("SELECT account, item_id, item_title, item_url, band_name FROM item LEFT JOIN item_tag USING (account, item_id) WHERE account = ? AND tag IS NULL").all(account)
this.total = this.untaggedItems.length this.total = this.untaggedItems.length
this.running = false this.running = false
this.outcome = null this.outcome = null
@ -24,16 +25,46 @@ class TagDownloader extends sync.reloadClassMethods(() => TagDownloader) {
if (this.running) return if (this.running) return
this.running = true this.running = true
try { try {
for (const {account, item_id, item_title, item_url} of this.untaggedItems) { for (const {account, item_id, item_title, item_url, band_name} of this.untaggedItems) {
const html = await fetch(item_url).then(res => res.text()) const res = await fetch(item_url)
// delete unreachable items, otherwise it will perpetually try to download tags for them
if (res.status === 404) {
db.prepare("DELETE FROM item WHERE account = ? AND item_id = ?").run(account, item_id)
this.processed++
continue
}
const html = await res.text()
const doc = domino.createDocument(html) const doc = domino.createDocument(html)
// @ts-ignore // @ts-ignore
const tags = [...doc.querySelectorAll(".tag").cache].map(e => e.textContent) const tags = [...doc.querySelectorAll(".tag").cache].map(e => e.textContent)
db.transaction(() => { db.transaction(() => {
for (const tag of tags) { for (const tag of tags) {
i.run(account, item_id, tag) insertTag.run(account, item_id, tag)
} }
})() })()
// @ts-ignore
const tracks = [...doc.querySelectorAll(".track_row_view").cache]
db.transaction(() => {
for (const track of tracks) {
const track_number = parseInt(track.querySelector(".track_number").textContent)
let title = track.querySelector(".track-title").textContent
let artist = band_name
const match = title.match(/^([^-]*) - (.*)$/)
if (match) {
artist = match[1]
title = match[2]
}
const duration = track.querySelector(".time").textContent.split(":").reverse().reduce((a, c, i) => 60**i * c + a, 0)
console.log(track_number, title, artist, duration)
if (!track_number || !title || !artist || !duration) continue
insertTrack.run(account, item_id, track_number, title, artist, track_number, duration)
}
})()
this.processed++ this.processed++
} }
this.outcome = "Success" this.outcome = "Success"

View file

@ -35,6 +35,7 @@ pugSync.createRoute(router, "/", "home.pug")
// Routes // Routes
sync.require("./routes/app") sync.require("./routes/app")
sync.require("./routes/load-collection")
// Files // Files