Multiple accounts support, load tags online

This commit is contained in:
Cadence Ember 2025-04-02 17:03:03 +13:00
parent cd2827791f
commit 26ea869285
14 changed files with 295 additions and 124 deletions

View file

@ -1,10 +1,13 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, getQuery, getValidatedQuery, sendRedirect, createError} = require("h3")
const {defineEventHandler, getQuery, getValidatedQuery, sendRedirect, createError, getValidatedRouterParams} = require("h3")
const {router, db, sync, select, from} = require("../passthrough")
const pugSync = sync.require("../pug-sync")
/** @type {import("./load-tags")} */
const loadTags = sync.require("./load-tags")
const displayCurrency = "NZD"
const displayCurrencySymbol = "$"
const currencyExchange = new Map([
@ -14,32 +17,38 @@ const currencyExchange = new Map([
["EUR", 1.08],
["GBP", 1.3],
["CAD", 0.7],
["NOK", 0.1]
["NOK", 0.1],
["CHF", 1.13]
])
const sqls = {
album_grid: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY item_id {ORDER}",
album_list: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY item_id ORDER BY band_url, band_name COLLATE NOCASE, item_title COLLATE NOCASE",
artist_grid: "SELECT band_name, count(DISTINCT item_id) AS album_count, group_concat(DISTINCT band_url) AS labels, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY band_name ORDER BY band_name COLLATE NOCASE",
artist_list: "SELECT band_name, count(DISTINCT item_id) AS album_count, band_url, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY band_name ORDER BY band_name COLLATE NOCASE",
label_grid: "SELECT iif(count(DISTINCT band_name) = 1, band_name, band_url) AS display_name, band_url, count(DISTINCT item_id) AS album_count, count(DISTINCT band_name) AS artist_count, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY band_url ORDER BY display_name COLLATE NOCASE",
label_list: "SELECT iif(count(DISTINCT band_name) = 1, band_name, band_url) AS display_name, band_url, count(DISTINCT item_id) AS album_count, count(DISTINCT band_name) AS artist_count, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (item_id) {JOIN TAG} {WHERE} GROUP BY band_url ORDER BY display_name COLLATE NOCASE",
tag_grid: "SELECT tag, count(*) AS count FROM (SELECT tag, band_url, band_name, item_id, count(*) AS count FROM item_tag INNER JOIN item USING (item_id) GROUP BY tag, band_url) {WHERE} GROUP BY tag ORDER BY count DESC",
track_list: "SELECT * FROM track INNER JOIN item USING (item_id) {JOIN TAG} {WHERE} ORDER BY band_url, item_title COLLATE NOCASE, track_number"
album_grid: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY item_id {ORDER}",
album_list: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY item_id ORDER BY band_url, band_name COLLATE NOCASE, item_title COLLATE NOCASE",
artist_grid: "SELECT band_name, count(DISTINCT item_id) AS album_count, group_concat(DISTINCT band_url) AS labels, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY band_name ORDER BY band_name COLLATE NOCASE",
artist_list: "SELECT band_name, count(DISTINCT item_id) AS album_count, band_url, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY band_name ORDER BY band_name COLLATE NOCASE",
label_grid: "SELECT iif(count(DISTINCT band_name) = 1, band_name, band_url) AS display_name, band_url, count(DISTINCT item_id) AS album_count, count(DISTINCT band_name) AS artist_count, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY band_url ORDER BY display_name COLLATE NOCASE",
label_list: "SELECT iif(count(DISTINCT band_name) = 1, band_name, band_url) AS display_name, band_url, count(DISTINCT item_id) AS album_count, count(DISTINCT band_name) AS artist_count, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY band_url ORDER BY display_name COLLATE NOCASE",
tag_grid: "SELECT tag, count(*) AS count FROM item_tag INNER JOIN item USING (account, item_id) WHERE account = ? {WHERE} GROUP BY tag, band_url ORDER BY count DESC",
track_list: "SELECT * FROM track INNER JOIN item USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} ORDER BY band_url, item_title COLLATE NOCASE, track_number"
}
function loadPreviews(locals, field, number, whereClause, filter_field, filter) {
const params = [number]
let sql = `SELECT ${field}, item_url, item_art_url FROM (SELECT ${field}, item_url, item_art_url, row_number() OVER (PARTITION BY ${field} ORDER BY purchased DESC) AS row_number FROM item {JOIN TAG} {WHERE}) WHERE row_number <= ?`
function loadPreviews(locals, field, number, whereClause, account, filter_field, filter) {
const params = [account, number]
let sql = `SELECT ${field}, item_url, item_art_url FROM (SELECT ${field}, item_url, item_art_url, row_number() OVER (PARTITION BY ${field} ORDER BY purchased DESC) AS row_number FROM item {JOIN TAG} WHERE account = ? {WHERE}) WHERE row_number <= ?`
sql = sql.replace("{WHERE}", whereClause)
if (whereClause) {
params.unshift(filter)
if (filter_field === "band_url") {
params.splice(1, 0, `%${filter}%`)
} else {
params.splice(1, 0, filter)
}
}
if (filter_field === "tag" && filter) {
sql = sql.replace("{JOIN TAG}", "INNER JOIN item_tag USING (item_id)")
sql = sql.replace("{JOIN TAG}", "INNER JOIN item_tag USING (account, item_id)")
} else {
sql = sql.replace("{JOIN TAG}", "")
}
const previews = db.prepare(sql).all(params)
// TODO: performance?
for (const item of locals.items) {
@ -52,20 +61,26 @@ function loadPreviews(locals, field, number, whereClause, filter_field, filter)
}
}
const schema = z.object({
arrange: z.enum(["album", "artist", "label", "tag", "track"]),
shape: z.enum(["grid", "list"]),
filter_field: z.enum(["band_name", "band_url", "item_id", "tag"]).optional(),
filter: z.string().optional()
})
const schema = {
query: z.object({
arrange: z.enum(["album", "artist", "label", "tag", "track"]),
shape: z.enum(["grid", "list"]),
filter_field: z.enum(["band_name", "band_url", "item_id", "tag"]).optional(),
filter: z.string().optional()
}),
params: z.object({
account: z.string()
})
}
router.get("/", defineEventHandler({
router.get("/:account/", defineEventHandler({
onBeforeResponse: pugSync.compressResponse,
handler: async event => {
try {
var {arrange, shape, filter, filter_field} = await getValidatedQuery(event, schema.parse)
var {account} = await getValidatedRouterParams(event, schema.params.parse)
var {arrange, shape, filter, filter_field} = await getValidatedQuery(event, schema.query.parse)
} catch (e) {
return sendRedirect(event, "/?arrange=album&shape=grid", 302)
return sendRedirect(event, "?arrange=album&shape=grid", 302)
}
const query = getQuery(event)
if (arrange === "track") {
@ -73,7 +88,7 @@ router.get("/", defineEventHandler({
query.shape = "list"
}
const mode = `${arrange}_${shape}`
const params = []
const params = [account]
let sql = sqls[mode]
let whereClause = ""
if (filter_field && filter) {
@ -84,23 +99,33 @@ router.get("/", defineEventHandler({
} else {
params.push(filter)
}
whereClause = `WHERE ${filter_field} ${operator} ?`
whereClause = `AND ${filter_field} ${operator} ?`
sql = sql.replace("{ORDER}", "ORDER BY item_title COLLATE NOCASE")
} else {
sql = sql.replace("{ORDER}", "ORDER BY purchased DESC")
}
sql = sql.replace("{WHERE}", whereClause)
if (filter_field === "tag" && filter) {
sql = sql.replace("{JOIN TAG}", "INNER JOIN item_tag USING (item_id)")
sql = sql.replace("{JOIN TAG}", "INNER JOIN item_tag USING (account, item_id)")
} else {
sql = sql.replace("{JOIN TAG}", "")
}
const prepared = db.prepare(sql)
if (arrange === "tag") {
prepared.raw()
if (mode === "tag_grid" && ((!filter_field || !filter) || filter_field === "tag")) {
sql = `SELECT tag, count(*) AS count FROM (${sql}) GROUP BY tag ORDER BY count DESC`
}
try {
const prepared = db.prepare(sql)
if (arrange === "tag") {
prepared.raw()
}
var items = prepared.all(params)
} catch (e) {
console.error(sql, params)
throw e
}
const locals = {
items: prepared.all(params),
items,
account,
query,
count: {
total: db.prepare("SELECT count(*) FROM item").pluck().get(),
@ -123,9 +148,12 @@ router.get("/", defineEventHandler({
}
}
if (mode === "artist_grid") {
loadPreviews(locals, "band_name", 4, whereClause, filter_field, filter)
loadPreviews(locals, "band_name", 4, whereClause, account, filter_field, filter)
} else if (mode === "label_grid") {
loadPreviews(locals, "band_url", 6, whereClause, filter_field, filter)
loadPreviews(locals, "band_url", 6, whereClause, account, filter_field, filter)
} else if (arrange === "tag") {
locals.downloadManager = loadTags.downloadManager
locals.downloader = loadTags.downloadManager.check(account)
}
return pugSync.render(event, `${arrange}_${shape}.pug`, locals)
}

99
routes/load-tags.js Normal file
View file

@ -0,0 +1,99 @@
// @ts-check
const domino = require("domino")
const {z} = require("zod")
const {getValidatedQuery, readValidatedBody, defineEventHandler} = require("h3")
const {sync, db, from, router} = require("../passthrough")
const pugSync = sync.require("../pug-sync")
const i = db.prepare("INSERT OR IGNORE INTO item_tag (account, item_id, tag) VALUES (?, ?, ?)")
class TagDownloader extends sync.reloadClassMethods(() => TagDownloader) {
constructor(account) {
super()
this.account = account
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.total = this.untaggedItems.length
this.running = false
this.outcome = null
}
async _start() {
if (this.running) return
this.running = true
try {
for (const {account, item_id, item_title, item_url} of this.untaggedItems) {
const html = await fetch(item_url).then(res => res.text())
const doc = domino.createDocument(html)
// @ts-ignore
const tags = [...doc.querySelectorAll(".tag").cache].map(e => e.textContent)
db.transaction(() => {
for (const tag of tags) {
i.run(account, item_id, tag)
}
})()
this.processed++
}
this.outcome = "Success"
} catch (e) {
console.error(e)
this.outcome = e.toString()
} finally {
this.running = false
}
}
}
const downloadManager = new class {
/** @type {Map<string, TagDownloader>} */
inProgressTagDownloads = sync.remember(() => new Map())
/** @param {string} account */
check(account) {
return this.inProgressTagDownloads.get(account) || (() => {
const downloader = new TagDownloader(account)
this.inProgressTagDownloads.set(account, downloader)
console.log(`created downloader ${account}`)
setTimeout(() => {
this.resolve(account)
})
return downloader
})()
}
/** @param {string} account */
start(account) {
const downloader = this.check(account)
downloader._start()
return downloader
}
/** @param {string} account */
resolve(account) {
const downloader = this.check(account)
if (!downloader.running) {
console.log(`disposed downloader ${account}`)
this.inProgressTagDownloads.delete(account)
}
}
}
const schema = z.object({
account: z.string()
})
router.get("/api/tag-download", defineEventHandler(async event => {
const {account} = await getValidatedQuery(event, schema.parse)
const downloader = downloadManager.check(account)
return pugSync.render(event, "includes/tag-status.pug", {downloadManager, downloader, account})
}))
router.post("/api/tag-download", defineEventHandler(async event => {
const {account} = await readValidatedBody(event, schema.parse)
const downloader = downloadManager.start(account)
return pugSync.render(event, "includes/tag-status.pug", {downloadManager, downloader, account})
}))
module.exports.downloadManager = downloadManager