diff --git a/fonthelper.js b/fonthelper.js new file mode 100644 index 0000000..7b16e18 --- /dev/null +++ b/fonthelper.js @@ -0,0 +1,40 @@ +// https://github.com/kvnang/workers-og/blob/main/packages/workers-og/src/font.ts +const fs = require("fs"); +async function loadGoogleFont({ family, weight, text }) { + const params = { + family: `${encodeURIComponent(family)}${weight ? `:wght@${weight}` : ""}`, + }; + + if (text) { + params.text = text; + } else { + params.subset = "latin"; + } + + const url = `https://fonts.googleapis.com/css2?${Object.keys(params) + .map((key) => `${key}=${params[key]}`) + .join("&")}`; + + let res = await fetch(`${url}`, { + headers: { + // construct user agent to get TTF font + "User-Agent": + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1", + }, + }); + + res = new Response(res.body, res); + res.headers.append("Cache-Control", "s-maxage=3600"); + + const body = await res.text(); + // Get the font URL from the CSS text + const fontUrl = body.match( + /src: url\((.+)\) format\('(opentype|truetype)'\)/, + )?.[1]; + + if (!fontUrl) { + throw new Error("Could not find font URL"); + } + + return fetch(fontUrl).then((res) => res.arrayBuffer()); +} diff --git a/package-lock.json b/package-lock.json index 0f6f3d6..6ae5029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "@atproto/api": "^0.13.18", "@imikailoby/lastfm-ts": "^2.0.1", - "@kellnerd/listenbrainz": "npm:@jsr/kellnerd__listenbrainz@^0.8.4" + "@kellnerd/listenbrainz": "npm:@jsr/kellnerd__listenbrainz@^0.8.4", + "workers-og": "^0.0.25" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241127.0", @@ -768,6 +769,14 @@ "@jsr/std__uuid": "^1.0.0" } }, + "node_modules/@resvg/resvg-wasm": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz", + "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz", @@ -1002,6 +1011,21 @@ "win32" ] }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@tauri-apps/cli": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.6.3.tgz", @@ -1370,6 +1394,14 @@ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1385,6 +1417,14 @@ "node": ">=8" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/capnp-ts": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/capnp-ts/-/capnp-ts-0.7.0.tgz", @@ -1435,6 +1475,11 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1444,6 +1489,34 @@ "node": ">= 0.6" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/data-uri-to-buffer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", @@ -1492,6 +1565,11 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" + }, "node_modules/es-module-lexer": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", @@ -1537,6 +1615,11 @@ "@esbuild/win32-x64": "0.24.0" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1579,6 +1662,11 @@ "node": ">=12.0.0" } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1635,6 +1723,17 @@ "node": ">= 0.4" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-core-module": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", @@ -1661,6 +1760,20 @@ "integrity": "sha512-+P8IZaLLBtFv8hCkIjcymZOp4UJ+xW6bSlQsXGqrkmJh7vSiMFSlNne0mCYagEE0N7HDNR5jJBRxwN0oYv61Rw==", "dev": true }, + "node_modules/just-camel-case": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-camel-case/-/just-camel-case-6.2.0.tgz", + "integrity": "sha512-ICenRLXwkQYLk3UyvLQZ+uKuwFVJ3JHFYFn7F2782G2Mv2hW8WPePqgdhpnjGaqkYtSVWnyCESZhGXUmY3/bEg==" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "node_modules/loupe": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", @@ -1767,6 +1880,20 @@ "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==", "dev": true }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1828,6 +1955,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/prettier": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", @@ -1976,6 +2108,26 @@ "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", "dev": true }, + "node_modules/satori": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.10.14.tgz", + "integrity": "sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-to-react-native": "^3.0.0", + "emoji-regex": "^10.2.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-wasm-web": "^0.3.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/selfsigned": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", @@ -2064,6 +2216,11 @@ "npm": ">=6" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2076,6 +2233,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2187,6 +2349,15 @@ "ufo": "^1.5.4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/vite": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.1.tgz", @@ -2381,6 +2552,17 @@ "@cloudflare/workerd-windows-64": "1.20241106.1" } }, + "node_modules/workers-og": { + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/workers-og/-/workers-og-0.0.25.tgz", + "integrity": "sha512-OkTyqCkUCUpGHwMwGmVCMtFPUASf9oBEiCYyOVMBDnUidTQt7AwvDx5EIuCMuQELUGm/tIyvvC8OU/hBsxlBUw==", + "dependencies": { + "@resvg/resvg-wasm": "^2.4.0", + "just-camel-case": "^6.2.0", + "satori": "^0.10.11", + "yoga-wasm-web": "^0.3.3" + } + }, "node_modules/wrangler": { "version": "3.91.0", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.91.0.tgz", @@ -2842,6 +3024,11 @@ "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", "dev": true }, + "node_modules/yoga-wasm-web": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", + "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==" + }, "node_modules/youch": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", diff --git a/package.json b/package.json index 8ac90b6..5404d2c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dependencies": { "@atproto/api": "^0.13.18", "@imikailoby/lastfm-ts": "^2.0.1", - "@kellnerd/listenbrainz": "npm:@jsr/kellnerd__listenbrainz@^0.8.4" + "@kellnerd/listenbrainz": "npm:@jsr/kellnerd__listenbrainz@^0.8.4", + "workers-og": "^0.0.25" }, "scripts": { "test": "vitest run" diff --git a/src/api-wrappers/bluesky.test.ts b/src/api-wrappers/bluesky.test.ts index b97fa5d..a87600b 100644 --- a/src/api-wrappers/bluesky.test.ts +++ b/src/api-wrappers/bluesky.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { BlueSky } from "./bluesky"; -import { AtpAgent, ComAtprotoServerCreateSession } from "@atproto/api"; +import { + AtpAgent, + ComAtprotoServerCreateSession, + RichText, +} from "@atproto/api"; import { mockEnv } from "../../tests/fixtures/env"; import { RateLimitError } from "../errors"; @@ -24,6 +28,11 @@ vi.mock("@atproto/api", () => { resumeSession: mockResumeSession, session: { did: "test-did" }, })), + RichText: vi.fn().mockImplementation(({ text }) => ({ + text, + facets: [], + detectFacets: vi.fn(), + })), }; }); @@ -158,6 +167,9 @@ describe("BlueSky", () => { vi.mocked(agent.login).mockResolvedValueOnce(mockLoginResponse); + const mockRichText = new RichText({ text: "Test message" }); + vi.mocked(RichText).mockReturnValueOnce(mockRichText); + vi.mocked(agent.post).mockResolvedValueOnce({ uri: "at://test-did/app.bsky.feed.post/test", cid: "test-cid", @@ -169,7 +181,8 @@ describe("BlueSky", () => { await bluesky.postMessage(message); expect(agent.post).toHaveBeenCalledWith({ - text: message, + text: mockRichText.text, + facets: mockRichText.facets, createdAt: expect.any(String), }); }); diff --git a/src/api-wrappers/bluesky.ts b/src/api-wrappers/bluesky.ts index d2d5f70..fbf7ff7 100644 --- a/src/api-wrappers/bluesky.ts +++ b/src/api-wrappers/bluesky.ts @@ -3,6 +3,7 @@ import { AppBskyActorDefs, AtpSessionEvent, AtpSessionData, + RichText, } from "@atproto/api"; import { Env } from "../types/env"; @@ -11,6 +12,15 @@ import { BlueskyRateLimitExceededError } from "../types/bluesky"; const SESSION_KEY = "session"; +interface PostMessageOptions { + altText?: string; + aspectRatio?: { + width: number; + height: number; + }; + embedImage?: Blob; +} + export class BlueSky { private agent: AtpAgent; @@ -75,10 +85,38 @@ export class BlueSky { } } - async postMessage(text: string): Promise { + async postMessage( + text: string, + options: PostMessageOptions = {}, + ): Promise { try { + let embed; + + if (options.embedImage) { + const uploadResponse = await this.agent.uploadBlob(options.embedImage, { + encoding: "image/jpeg", + }); + + embed = { + $type: "app.bsky.embed.images", + images: [ + { + alt: options.altText, + image: uploadResponse.data.blob, + aspectRatio: options.aspectRatio, + }, + ], + }; + } + + const richText = new RichText({ text }); + + await richText.detectFacets(this.agent); + await this.agent.post({ - text, + text: richText.text, + facets: richText.facets, + embed, createdAt: new Date().toISOString(), }); } catch (error) { diff --git a/src/api-wrappers/lastfm.test.ts b/src/api-wrappers/lastfm.test.ts index bae87dd..c1fff5e 100644 --- a/src/api-wrappers/lastfm.test.ts +++ b/src/api-wrappers/lastfm.test.ts @@ -1,16 +1,22 @@ import { vi } from "vitest"; const mockGetRecentTracks = vi.fn(); +const mockGetWeeklyArtistChart = vi.fn(); +const mockGetArtistInfo = vi.fn(); vi.mock("@imikailoby/lastfm-ts", () => ({ LastFm: vi.fn(() => ({ user: { getRecentTracks: mockGetRecentTracks, + getWeeklyArtistChart: mockGetWeeklyArtistChart, + }, + artist: { + getInfo: mockGetArtistInfo, }, })), })); -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { LastFM } from "./lastfm"; import { RecentTrack, RecentTracksResponse } from "./lastfm.types"; import { LastFm } from "@imikailoby/lastfm-ts"; @@ -135,4 +141,130 @@ describe("LastFM", () => { }); }); }); + + describe("getWeeklyTopArtists", () => { + const lastfm = new LastFM(mockEnv); + + it("should fetch and normalize top artists", async () => { + const mockTopArtists = [ + { name: "Artist 1", playcount: "10", mbid: "", url: "" }, + { name: "Artist 2", playcount: "8", mbid: "", url: "" }, + { name: "Artist 3", playcount: "5", mbid: "", url: "" }, + ]; + + mockGetWeeklyArtistChart.mockResolvedValueOnce({ + weeklyartistchart: { + "@attr": { + from: "1234567890", + to: "1234567890", + user: mockEnv.LASTFM_USERNAME, + }, + artist: mockTopArtists, + }, + }); + + mockGetArtistInfo + .mockResolvedValueOnce({ + artist: { + name: "Artist 1", + url: "https://last.fm/artist/1", + image: [{ "#text": "https://lastfm.com/i/u/300x300/artist1.jpg" }], + }, + }) + .mockResolvedValueOnce({ + artist: { + name: "Artist 2", + url: "https://last.fm/artist/2", + image: [{ "#text": "https://lastfm.com/i/u/300x300/artist2.jpg" }], + }, + }) + .mockResolvedValueOnce({ + artist: { + name: "Artist 3", + url: "https://last.fm/artist/3", + image: [{ "#text": "https://lastfm.com/i/u/300x300/artist3.jpg" }], + }, + }); + + const result = await lastfm.getWeeklyTopArtists(); + + expect(mockGetWeeklyArtistChart).toHaveBeenCalledWith({ + user: mockEnv.LASTFM_USERNAME, + }); + + expect(result).toEqual([ + { + name: "Artist 1", + playcount: 10, + image: "https://lastfm.com/i/u/300x300/artist1.jpg", + }, + { + name: "Artist 2", + playcount: 8, + image: "https://lastfm.com/i/u/300x300/artist2.jpg", + }, + { + name: "Artist 3", + playcount: 5, + image: "https://lastfm.com/i/u/300x300/artist3.jpg", + }, + ]); + }); + + it("should return empty array when no artists found", async () => { + mockGetWeeklyArtistChart.mockResolvedValueOnce({ + weeklyartistchart: { + "@attr": { + from: "1234567890", + to: "1234567890", + user: mockEnv.LASTFM_USERNAME, + }, + artist: [], + }, + }); + + const result = await lastfm.getWeeklyTopArtists(); + + expect(result).toEqual([]); + }); + + it("should return empty array when API call fails", async () => { + mockGetWeeklyArtistChart.mockRejectedValueOnce(new Error("API Error")); + + const result = await lastfm.getWeeklyTopArtists(); + + expect(result).toEqual([]); + }); + }); + + describe("getArtistInfo", () => { + const lastfm = new LastFM(mockEnv); + + it("should fetch and normalize artist info", async () => { + mockGetArtistInfo.mockResolvedValueOnce({ + artist: { + name: "Test Artist", + stats: { userplaycount: "42" }, + url: "https://last.fm/artist/test", + tags: { tag: [{ name: "rock" }, { name: "indie" }] }, + bio: { summary: "Test bio" }, + image: [{ "#text": "image-url" }], + }, + }); + + const result = await lastfm.getArtistInfo("Test Artist"); + + expect(mockGetArtistInfo).toHaveBeenCalledWith({ + artist: "Test Artist", + username: mockEnv.LASTFM_USERNAME, + lang: "en", + }); + + expect(result).toEqual({ + name: "Test Artist", + images: ["image-url"], + url: "https://last.fm/artist/test", + }); + }); + }); }); diff --git a/src/api-wrappers/lastfm.ts b/src/api-wrappers/lastfm.ts index 5468d21..6ea0953 100644 --- a/src/api-wrappers/lastfm.ts +++ b/src/api-wrappers/lastfm.ts @@ -1,7 +1,10 @@ -import { LastFm } from "@imikailoby/lastfm-ts"; +import { Artist, LastFm } from "@imikailoby/lastfm-ts"; import { RecentTrack } from "./lastfm.types"; import { Env } from "../types/env"; import { NormalizedTrack } from "../types/track"; +import { WeeklyTopArtist } from "../types/lastfm"; +import { NormalizedWeeklyTopArtist } from "../types/weekly-top-artist"; +import { NormalizedArtist } from "../types/artist"; export class LastFM { private client: LastFm; @@ -16,6 +19,40 @@ export class LastFM { this.username = env.LASTFM_USERNAME; } + async getWeeklyTopArtists(limit = 3): Promise { + try { + const response = await this.client.user.getWeeklyArtistChart({ + user: this.username, + }); + + const artists = response.weeklyartistchart?.artist.slice(0, limit); + + if (!artists?.length) { + return []; + } + + const artistsWithData = await Promise.all( + artists.map(async (artist) => { + const artistInfo = await this.getArtistInfo(artist.name); + if (!artistInfo) return; + return { + ...artistInfo, + playcount: artist.playcount, + }; + }), + ).then((results) => + results.filter( + (r): r is NormalizedArtist & { playcount: string } => r !== undefined, + ), + ); + + return artistsWithData.map(this.normalizeWeeklyTopArtist); + } catch (error) { + console.error("Failed to fetch top artists:", error); + return []; + } + } + async getLatestSong(): Promise { try { const response = await this.client.user.getRecentTracks({ @@ -34,6 +71,41 @@ export class LastFM { } } + async getArtistInfo( + artistName: string, + ): Promise { + try { + const response = await this.client.artist.getInfo({ + artist: artistName, + username: this.username, + lang: "en", + }); + + if (!response.artist) { + return; + } + + return this.normalizeArtist(response.artist); + } catch (error) { + console.error(`Failed to fetch artist info for ${artistName}:`, error); + return; + } + } + + private normalizeArtist = (artist: Artist): NormalizedArtist => ({ + name: artist.name, + images: artist.image.map((image) => image["#text"]), + url: artist.url, + }); + + private normalizeWeeklyTopArtist = ( + artist: NormalizedArtist & { playcount: string }, + ): NormalizedWeeklyTopArtist => ({ + name: artist.name, + image: artist.images.filter((image) => image.match(/i\/u\/300x300/))[0], + playcount: parseInt(artist.playcount), + }); + private normalizeTrack( track: RecentTrack | undefined, ): NormalizedTrack | undefined { diff --git a/src/index.ts b/src/index.ts index 437faff..0df0e97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,56 @@ +import { skeetWeeklyTopArtists } from "./services/skeet-weekly-top-artists"; import { sync } from "./services/sync"; import { Env } from "./types/env"; +import { LastFM } from "./api-wrappers/lastfm"; +import { WeeklyTopArtistsImageGenerator } from "./services/weekly-top-artists-image-generator"; export default { async scheduled(event: ScheduledEvent, env: Env): Promise { console.log("Scheduled event started at:", new Date().toISOString()); - await sync(env); + switch (event.cron) { + // Every minute + case "* * * * *": + // Sync the Bluesky profile with the latest track + await sync(env); + break; + + // Every Monday at 10am EST + case "0 10 * * 1": + // Post weekly top artists with image to Bluesky + await skeetWeeklyTopArtists(env); + break; + + default: + throw new Error(`Unknown cron schedule: ${event.cron}`); + } + }, + + // Note that this endpoint is largely used for local development. It does not have any form of + // caching mechanism, so if linked to directly it could be rate limited by Last.fm. + async fetch(request: Request, env: Env): Promise { + // Only allow GET requests + if (request.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + try { + const lastfm = new LastFM(env); + const topArtists = await lastfm.getWeeklyTopArtists(5); + + const imageResponse = await new WeeklyTopArtistsImageGenerator( + topArtists, + ).generate(); + + if (!imageResponse) { + return new Response("No data available", { status: 404 }); + } + + // Return the image response directly + return imageResponse; + } catch (error) { + console.error("Error generating image:", error); + return new Response("Internal server error", { status: 500 }); + } }, }; diff --git a/src/services/skeet-weekly-top-artists.test.ts b/src/services/skeet-weekly-top-artists.test.ts new file mode 100644 index 0000000..286aa6a --- /dev/null +++ b/src/services/skeet-weekly-top-artists.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { skeetWeeklyTopArtists } from "./skeet-weekly-top-artists"; +import { LastFM } from "../api-wrappers/lastfm"; +import { BlueSky } from "../api-wrappers/bluesky"; +import { WeeklyTopArtistsImageGenerator } from "./weekly-top-artists-image-generator"; +import { mockEnv } from "../../tests/fixtures/env"; + +// Mock dependencies +vi.mock("../api-wrappers/lastfm"); +vi.mock("../api-wrappers/bluesky"); +vi.mock("./weekly-top-artists-image-generator"); +vi.mock("workers-og", () => ({ + ImageResponse: vi.fn().mockImplementation(() => new Response()), +})); + +describe("skeetWeeklyTopArtists", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully post weekly top artists to Bluesky", async () => { + // Mock data + const mockTopArtists = [ + { name: "Artist 1", playcount: 10, image: "url1" }, + { name: "Artist 2", playcount: 8, image: "url2" }, + { name: "Artist 3", playcount: 6, image: "url3" }, + { name: "Artist 4", playcount: 4, image: "url4" }, + { name: "Artist 5", playcount: 2, image: "url5" }, + ]; + + // Mock LastFM + vi.mocked(LastFM.prototype.getWeeklyTopArtists).mockResolvedValue( + mockTopArtists, + ); + + // Mock BlueSky + const mockBlueskyInstance = { postMessage: vi.fn() }; + vi.mocked(BlueSky.retrieveAgent).mockResolvedValue( + mockBlueskyInstance as any, + ); + + // Mock image generator + const mockImageResponse = new Response(new Blob(["mock-image"])); + const mockAltText = "My weekly top artists on Last.fm"; + + vi.mocked( + WeeklyTopArtistsImageGenerator.prototype.generate, + ).mockResolvedValue(mockImageResponse); + vi.mocked( + WeeklyTopArtistsImageGenerator.prototype.altText, + ).mockResolvedValue(mockAltText); + + // Execute + await skeetWeeklyTopArtists(mockEnv); + + // Verify + expect(LastFM.prototype.getWeeklyTopArtists).toHaveBeenCalledWith(5); + expect(BlueSky.retrieveAgent).toHaveBeenCalledWith(mockEnv); + expect( + WeeklyTopArtistsImageGenerator.prototype.generate, + ).toHaveBeenCalled(); + expect(WeeklyTopArtistsImageGenerator.prototype.altText).toHaveBeenCalled(); + expect(mockBlueskyInstance.postMessage).toHaveBeenCalledWith( + "Here are my top artists I listened to this last week! 🎸", + { + embedImage: expect.any(Blob), + altText: mockAltText, + aspectRatio: { + width: WeeklyTopArtistsImageGenerator.width, + height: WeeklyTopArtistsImageGenerator.height, + }, + }, + ); + }); + + it("should do nothing if image generation fails", async () => { + // Mock data + const mockTopArtists = [ + { name: "Artist 1", playcount: 10, image: "url1" }, + { name: "Artist 2", playcount: 8, image: "url2" }, + ]; + + // Mock dependencies + vi.mocked(LastFM.prototype.getWeeklyTopArtists).mockResolvedValue( + mockTopArtists, + ); + const mockBlueskyInstance = { postMessage: vi.fn() }; + vi.mocked(BlueSky.retrieveAgent).mockResolvedValue( + mockBlueskyInstance as any, + ); + vi.mocked( + WeeklyTopArtistsImageGenerator.prototype.generate, + ).mockResolvedValue(undefined); + + // Execute + await skeetWeeklyTopArtists(mockEnv); + + // Verify + expect(mockBlueskyInstance.postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/skeet-weekly-top-artists.ts b/src/services/skeet-weekly-top-artists.ts new file mode 100644 index 0000000..98e9114 --- /dev/null +++ b/src/services/skeet-weekly-top-artists.ts @@ -0,0 +1,33 @@ +import { Env } from "../types/env"; +import { LastFM } from "../api-wrappers/lastfm"; +import { WeeklyTopArtistsImageGenerator } from "./weekly-top-artists-image-generator"; +import { BlueSky } from "../api-wrappers/bluesky"; + +export const skeetWeeklyTopArtists = async (env: Env) => { + const lastfm = new LastFM(env); + const bluesky = await BlueSky.retrieveAgent(env); + + const topArtists = await lastfm.getWeeklyTopArtists(5); + + const imageGenerator = new WeeklyTopArtistsImageGenerator(topArtists); + const imageResponse = await imageGenerator.generate(); + + if (!imageResponse) { + return; + } + + const embedImage = await imageResponse.blob(); + const altText = await imageGenerator.altText(); + + await bluesky.postMessage( + "Here are my top artists I listened to this last week! 🎸", + { + embedImage, + altText, + aspectRatio: { + width: WeeklyTopArtistsImageGenerator.width, + height: WeeklyTopArtistsImageGenerator.height, + }, + }, + ); +}; diff --git a/src/services/weekly-top-artists-image-generator.test.ts b/src/services/weekly-top-artists-image-generator.test.ts new file mode 100644 index 0000000..c2c5dd3 --- /dev/null +++ b/src/services/weekly-top-artists-image-generator.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; +import { WeeklyTopArtistsImageGenerator } from "./weekly-top-artists-image-generator"; + +vi.mock("workers-og", () => ({ + ImageResponse: vi.fn().mockImplementation(() => new Response()), + loadGoogleFont: vi.fn().mockResolvedValue(new Uint8Array()), +})); + +// Mock Canvas API +class MockOffscreenCanvas { + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + getContext() { + return { + fillStyle: "", + fillRect: vi.fn(), + fillText: vi.fn(), + drawImage: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + arcTo: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + }; + } + + convertToBlob() { + return Promise.resolve(new Blob()); + } +} + +// @ts-ignore +global.OffscreenCanvas = MockOffscreenCanvas; +global.createImageBitmap = vi.fn().mockResolvedValue({}); + +describe("WeeklyTopArtistsImageGenerator", () => { + it("should generate an image with artists", async () => { + const artists = [ + { + name: "Top Artist", + playcount: 100, + image: "https://example.com/image.jpg", + }, + { + name: "Second Artist", + playcount: 80, + image: "https://example.com/image2.jpg", + }, + { + name: "Third Artist", + playcount: 60, + image: "https://example.com/image3.jpg", + }, + ]; + + const generator = new WeeklyTopArtistsImageGenerator(artists); + const result = await generator.generate(); + expect(result).toBeInstanceOf(Response); + }); + + it("should return undefined when no artists provided", async () => { + const generator = new WeeklyTopArtistsImageGenerator([]); + const result = await generator.generate(); + expect(result).toBeUndefined(); + }); + + it("should return undefined when main artist has no image", async () => { + const artists = [ + { + name: "Top Artist", + playcount: 100, + image: undefined, + }, + ]; + + const generator = new WeeklyTopArtistsImageGenerator(artists); + const result = await generator.generate(); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/services/weekly-top-artists-image-generator.ts b/src/services/weekly-top-artists-image-generator.ts new file mode 100644 index 0000000..90175bb --- /dev/null +++ b/src/services/weekly-top-artists-image-generator.ts @@ -0,0 +1,48 @@ +/// + +import { ImageResponse, loadGoogleFont } from "workers-og"; +import { NormalizedWeeklyTopArtist } from "../types/weekly-top-artist"; +import { weeklyTopArtistsImageTemplate } from "./weekly-top-artists-image-template"; + +export class WeeklyTopArtistsImageGenerator { + static width = 1200; + static height = 630; + + constructor(private artists: NormalizedWeeklyTopArtist[]) {} + + async altText(): Promise { + let altText = "A weekly chart of top artists.\n\n"; + + this.artists.forEach((artist, index) => { + altText += `#${index + 1}: ${artist.name} with ${artist.playcount} plays\n`; + }); + + return altText; + } + + async generate(): Promise { + if (!this.artists.length) { + return; + } + + const mainArtist = this.artists[0]; + + // For now, we only support main artists with images will design a fallback approach later + if (!mainArtist?.image) { + return; + } + + const html = weeklyTopArtistsImageTemplate(mainArtist, this.artists); + + return new ImageResponse(html, { + width: WeeklyTopArtistsImageGenerator.width, + height: WeeklyTopArtistsImageGenerator.height, + fonts: [ + { + name: "Inter", + data: await loadGoogleFont({ family: "Inter" }), + }, + ], + }); + } +} diff --git a/src/services/weekly-top-artists-image-template.test.ts b/src/services/weekly-top-artists-image-template.test.ts new file mode 100644 index 0000000..75bd239 --- /dev/null +++ b/src/services/weekly-top-artists-image-template.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { weeklyTopArtistsImageTemplate } from "./weekly-top-artists-image-template"; +import { NormalizedWeeklyTopArtist } from "../types/weekly-top-artist"; + +describe("weeklyTopArtistsImageTemplate", () => { + it("should include main artist name, image and playcount", () => { + const mainArtist: NormalizedWeeklyTopArtist = { + name: "Test Artist", + playcount: 100, + image: "https://example.com/image.jpg", + }; + + const html = weeklyTopArtistsImageTemplate(mainArtist, [mainArtist]); + + expect(html).toContain(mainArtist.name); + expect(html).toContain(`${mainArtist.playcount} plays`); + expect(html).toContain(mainArtist.image); + expect(html).toContain("TOP ARTIST"); + }); + + it("should list all artists with their playcounts", () => { + const artists: NormalizedWeeklyTopArtist[] = [ + { name: "Artist 1", playcount: 100, image: "url1" }, + { name: "Artist 2", playcount: 80, image: "url2" }, + { name: "Artist 3", playcount: 60, image: "url3" }, + ]; + + const mainArtist = artists[0]!; + const html = weeklyTopArtistsImageTemplate(mainArtist, artists); + + artists.forEach((artist, index) => { + expect(html).toContain(artist.name); + expect(html).toContain(`${artist.playcount} plays`); + expect(html).toContain(`>${index + 1}<`); // Check ranking number + }); + }); + + it("should include branding elements", () => { + const mainArtist: NormalizedWeeklyTopArtist = { + name: "Test Artist", + playcount: 100, + image: "url", + }; + + const html = weeklyTopArtistsImageTemplate(mainArtist, [mainArtist]); + + expect(html).toContain("scrobble.blue"); + expect(html).toContain("Top artists"); + expect(html).toContain("of the week"); + }); + + it("should handle special characters in artist names", () => { + const mainArtist: NormalizedWeeklyTopArtist = { + name: "Artist & Co.", + playcount: 100, + image: "url", + }; + + const html = weeklyTopArtistsImageTemplate(mainArtist, [mainArtist]); + + expect(html).toContain("Artist & Co."); + }); +}); diff --git a/src/services/weekly-top-artists-image-template.ts b/src/services/weekly-top-artists-image-template.ts new file mode 100644 index 0000000..d65b461 --- /dev/null +++ b/src/services/weekly-top-artists-image-template.ts @@ -0,0 +1,86 @@ +import { NormalizedWeeklyTopArtist } from "../types/weekly-top-artist"; + +const ACCENT_COLOR = "#00FFFF"; // Electric blue + +export const weeklyTopArtistsImageTemplate = ( + mainArtist: NormalizedWeeklyTopArtist, + artists: NormalizedWeeklyTopArtist[], +) => ` +
+
+ +
+ +
+
+ ${ + mainArtist + ? ` +
+
+
+ +
+
+
+
+
+
+ TOP ARTIST +
+
+

${mainArtist.name}

+

${mainArtist.playcount} plays

+
+
+
+
+ ` + : "" + } +
+
+
+
+

+ + + Top artists + + of the week +

+
+
+ ${artists + .map( + (artist, index) => ` +
+
+

+ ${index + 1} +
+ ${artist.name} + ${artist.playcount} plays +
+

+
+
+ `, + ) + .join("")} +
+
+
+ + Powered by scrobble.blue + +
+
+
+
+`; diff --git a/src/types/artist.ts b/src/types/artist.ts new file mode 100644 index 0000000..5306880 --- /dev/null +++ b/src/types/artist.ts @@ -0,0 +1,5 @@ +export interface NormalizedArtist { + name: string; + images: string[]; + url: string; +} diff --git a/src/types/lastfm.ts b/src/types/lastfm.ts new file mode 100644 index 0000000..90f52da --- /dev/null +++ b/src/types/lastfm.ts @@ -0,0 +1,4 @@ +export interface WeeklyTopArtist { + name: string; + playcount: string; +} diff --git a/src/types/weekly-top-artist.ts b/src/types/weekly-top-artist.ts new file mode 100644 index 0000000..d0be5a9 --- /dev/null +++ b/src/types/weekly-top-artist.ts @@ -0,0 +1,5 @@ +export interface NormalizedWeeklyTopArtist { + name: string; + image: string | undefined; + playcount: number; +} diff --git a/wrangler.toml.example b/wrangler.toml.example index 010d1c9..61a21ce 100644 --- a/wrangler.toml.example +++ b/wrangler.toml.example @@ -7,7 +7,10 @@ kv_namespaces = [ ] [triggers] -crons = [ "* * * * *" ] +crons = [ + "* * * * *", # Every minute for track sync + "0 10 * * 1" # Every Monday at 10am EST for weekly top artists +] [observability.logs] enabled = true