From 44e1b73b1fd81af6a5932486bf1c352f3af68e1d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 31 Mar 2025 23:59:08 +1300 Subject: [PATCH] Add tag cloud --- package-lock.json | 7 ++++ package.json | 1 + pug/album_grid.pug | 6 +-- pug/includes/layout.pug | 23 ++++++------ pug/tag_grid.pug | 40 ++++++++++++++++++++ routes/app.js | 83 +++++++++++++++++++++++++++++------------ server.js | 10 +++++ 7 files changed, 131 insertions(+), 39 deletions(-) create mode 100644 pug/tag_grid.pug diff --git a/package-lock.json b/package-lock.json index be5cb69..069bdaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "h3": "^1.15.1", "heatsync": "^2.8.1", "htmx.org": "^2.0.4", + "wordcloud": "^1.2.3", "zod": "^3.24.2" } }, @@ -983,6 +984,12 @@ "node": ">= 10.0.0" } }, + "node_modules/wordcloud": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wordcloud/-/wordcloud-1.2.3.tgz", + "integrity": "sha512-9by77b7Sd9e1K75kSmVeAD+JnGpiLR1Z4EX1mYQL91jKrU1/4bHw4h4DExQ+dzfT+PvihDcH7OS7V4Y5UkbF2w==", + "license": "MIT" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 3b15953..b77a8e7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "h3": "^1.15.1", "heatsync": "^2.8.1", "htmx.org": "^2.0.4", + "wordcloud": "^1.2.3", "zod": "^3.24.2" } } diff --git a/pug/album_grid.pug b/pug/album_grid.pug index c842ffe..14cedb2 100644 --- a/pug/album_grid.pug +++ b/pug/album_grid.pug @@ -9,12 +9,12 @@ block view img(loading="lazy" src=item.item_art_url width=210 height=210) p.fs-body3.mb8= item.item_title .d-flex.fw-wrap.g4 - span.s-tag.s-tag__xs - span.s-tag--sponsor!= icons.get("compact-disc", 16) - = item.total_duration a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "item_id", filter: item.item_id})) span.s-tag--sponsor!= icons.get("music-note", 16) = item.track_count + span.s-tag.s-tag__xs + span.s-tag--sponsor!= icons.get("compact-disc", 16) + = item.total_duration a.s-tag.s-tag__xs(href=and({filter_field: "band_name", filter: item.band_name})) span.s-tag--sponsor!= icons.get("people-tag", 16) = item.band_name diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug index 19e59e4..1c8fd72 100644 --- a/pug/includes/layout.pug +++ b/pug/includes/layout.pug @@ -14,6 +14,7 @@ html title BC Explorer link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") script(src="/static/htmx.js") + script(src="/static/wordcloud.js") meta(name="htmx-config" content='{"requestClass":"is-loading"}') style. .themed { @@ -40,11 +41,17 @@ html .s-sidebarwidget th { font-weight: normal; color: var(--black-400); + text-align: right; } .album-grid-link { --_li-fc: var(--black); --_li-fc-visited: var(--black-400); } + .wc-hl { + cursor: pointer; + color: blue; + outline: 1px solid black; + } body.themed.theme-system.overflow-y-scroll(hx-boost="true") header.s-topbar .s-topbar--container.wmx9 @@ -81,15 +88,7 @@ html .s-sidebarwidget.wmn3 .s-sidebarwidget--header Collection table.s-sidebarwidget--content.s-sidebarwidget__items - tr.s-sidebarwidget--item - th albums - td= albumCount - tr.s-sidebarwidget--item - th singles - td= singleCount - tr.s-sidebarwidget--item - th tracks - td= trackCount - tr.s-sidebarwidget--item - th value - td #{displayCurrencySymbol}#{purchaseValue} #{displayCurrency} + each stat in count + tr.s-sidebarwidget--item + th= stat[0] + td= stat[1] diff --git a/pug/tag_grid.pug b/pug/tag_grid.pug new file mode 100644 index 0000000..8a857b7 --- /dev/null +++ b/pug/tag_grid.pug @@ -0,0 +1,40 @@ +extends includes/layout.pug + +block view + .mx-auto.w100.wmx11.fs-body1#content(style="cursor: default") + script + | var items = + != JSON.stringify(items) + script. + setTimeout(() => { + const content = document.getElementById("content") + content.style.height = `${Math.round(Math.min(content.clientWidth, window.innerHeight)*0.8)}px` + WordCloud(content, { + list: items, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI Adjusted", "Segoe UI", "Liberation Sans", sans-serif', + fontWeight: "bold", + color: "random-dark", + minSize: 4, + gridSize: Math.round(content.clientWidth / 200), + weightFactor: size => size * content.clientWidth / 180, + rotateRatio: 0, + click: item => { + const highlightedItem = document.querySelector(".wc-hl")?.textContent || item[0] + const newURL = new URL(location) + newURL.searchParams.set("filter", highlightedItem) + newURL.searchParams.set("filter_field", "tag") + newURL.searchParams.set("arrange", "label") + location = newURL + } + }) + content.addEventListener("wordcloudstop", () => { + for (const child of content.children) { + child.addEventListener("mouseenter", () => { + child.classList.add("wc-hl") + }) + child.addEventListener("mouseleave", () => { + child.classList.remove("wc-hl") + }) + } + }) + }) diff --git a/routes/app.js b/routes/app.js index 4d3d3a6..1ed42de 100644 --- a/routes/app.js +++ b/routes/app.js @@ -18,17 +18,29 @@ const currencyExchange = new Map([ ]) 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) {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) {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) {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) {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) {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) {WHERE} GROUP BY band_url ORDER BY display_name COLLATE NOCASE", - track_list: "SELECT * FROM track INNER JOIN item USING (item_id) {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 (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" } -function loadPreviews(locals, field, number) { - const previews = db.prepare(`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) WHERE row_number <= ?`).all(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 <= ?` + sql = sql.replace("{WHERE}", whereClause) + if (whereClause) { + params.unshift(filter) + } + if (filter_field === "tag" && filter) { + sql = sql.replace("{JOIN TAG}", "INNER JOIN item_tag USING (item_id)") + } else { + sql = sql.replace("{JOIN TAG}", "") + } + const previews = db.prepare(sql).all(params) // TODO: performance? for (const item of locals.items) { item.previews = [] @@ -43,7 +55,7 @@ function loadPreviews(locals, field, number) { 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"]).optional(), + filter_field: z.enum(["band_name", "band_url", "item_id", "tag"]).optional(), filter: z.string().optional() }) @@ -63,31 +75,54 @@ router.get("/", defineEventHandler({ const mode = `${arrange}_${shape}` const params = [] let sql = sqls[mode] + let whereClause = "" if (filter_field && filter) { - sql = sql.replace("{WHERE}", `WHERE ${filter_field} LIKE ?`) + let operator = "=" + if (filter_field === "band_url") { + operator = "LIKE" + params.push(`%${filter}%`) + } else { + params.push(filter) + } + whereClause = `WHERE ${filter_field} ${operator} ?` sql = sql.replace("{ORDER}", "ORDER BY item_title COLLATE NOCASE") - params.push(`%${filter}%`) } else { - sql = sql.replace("{WHERE}", "") 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)") + } else { + sql = sql.replace("{JOIN TAG}", "") + } const prepared = db.prepare(sql) + if (arrange === "tag") { + prepared.raw() + } const locals = { items: prepared.all(params), - albumCount: db.prepare("SELECT count(*) FROM item WHERE item_type = 'album'").pluck().get(), - singleCount: db.prepare("SELECT count(*) FROM item WHERE item_type = 'track'").pluck().get(), - trackCount: db.prepare("SELECT count(*) FROM track").pluck().get(), - purchaseValue: Math.round(select("item", ["currency", "price"]).all().map(c => { - return (currencyExchange.get(c.currency) || 0.5) * c.price / (currencyExchange.get(displayCurrency) || 1) / 10 - }).reduce((a, c) => a + c, 0)) * 10, - displayCurrencySymbol, - displayCurrency, - query + query, + count: [ + ["total", db.prepare("SELECT count(*) FROM item").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").pluck().get()], + ["albums", db.prepare("SELECT count(*) FROM item WHERE item_type = 'album'").pluck().get()], + ["singles", db.prepare("SELECT count(*) FROM item WHERE item_type = 'track'").pluck().get()], + ["free", db.prepare("SELECT count(*) FROM item WHERE price = 0").pluck().get()], + ["paid", db.prepare("SELECT count(*) FROM item WHERE price > 0").pluck().get()], + ["tracks", db.prepare("SELECT count(*) FROM track").pluck().get()], + ["avg tracks", 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], + ["tags", db.prepare("SELECT count(*) FROM item_tag").pluck().get()], + ["avg tags", Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM item_tag GROUP BY item_id)").pluck().get()*10)/10], + ["lonely tags", db.prepare("SELECT count(*) FROM (SELECT tag FROM item_tag GROUP BY tag HAVING count(*) = 1)").pluck().get()], + ["value", displayCurrencySymbol + Math.round(select("item", ["currency", "price"]).all().map(c => { + return (currencyExchange.get(c.currency) || 0.6) * c.price / (currencyExchange.get(displayCurrency) || 1) / 10 + }).reduce((a, c) => a + c, 0)) * 10 + " " + displayCurrency] + ] } if (mode === "artist_grid") { - loadPreviews(locals, "band_name", 4) + loadPreviews(locals, "band_name", 4, whereClause, filter_field, filter) } else if (mode === "label_grid") { - loadPreviews(locals, "band_url", 6) + loadPreviews(locals, "band_url", 6, whereClause, filter_field, filter) } return pugSync.render(event, `${arrange}_${shape}.pug`, locals) } diff --git a/server.js b/server.js index 4d2f31b..15537f3 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHead const passthrough = require("./passthrough") const sync = new HeatSync() +sync.events.on("error", console.error) passthrough.db = new sqlite("bc-explorer.db") const app = createApp() const router = createRouter() @@ -56,4 +57,13 @@ router.get("/static/htmx.js", defineEventHandler({ } })) +router.get("/static/wordcloud.js", defineEventHandler({ + onBeforeResponse: pugSync.compressResponse, + handler: async event => { + handleCacheHeaders(event, {maxAge: 86400}) + defaultContentType(event, "text/javascript") + return fs.promises.readFile(require.resolve("wordcloud/src/wordcloud2.js"), "utf-8") + } +})) + createServer(toNodeListener(app)).listen(2239)