corpus

Log | Files | Refs | README | LICENSE

commit 4f9a89ce5d24a41211ff9d220f73fb72b12435fa
parent 3ab2cc87e80dbabb273e984b071a9f31e84a9314
Author: Miro Toman <44134862+mtmn@users.noreply.github.com>
Date:   Wed, 22 Apr 2026 22:00:47 +0200

Merge pull request #19 from mtmn/feat/decouple-blocks-avif-conversion

feat: decouple large files, convert to avif, housekeeping
Diffstat:
M.github/workflows/release-please.yml | 23-----------------------
A.github/workflows/update-nix-hash.yml | 29+++++++++++++++++++++++++++++
MREADME.md | 1-
Mdocs/architecture.md | 1-
Mflake.nix | 4++--
Mpackage-lock.json | 522++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 7++++---
Mspago.lock | 16++++++++++++++++
Mspago.yaml | 1+
Asrc/Api.elm | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Client.elm | 1415+------------------------------------------------------------------------------
Msrc/Config.purs | 3---
Asrc/Cosine.purs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Cover.purs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Image.js | 11+++++++++++
Asrc/Image.purs | 12++++++++++++
Msrc/Main.purs | 827++++++-------------------------------------------------------------------------
Asrc/Metadata.purs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/State.elm | 502+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Sync.purs | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Templates.purs | 7+------
Asrc/Types.elm | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/View.elm | 700+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/Main.purs | 4+++-
Musers.dhall | 3---
Musers.json | 2--
26 files changed, 3045 insertions(+), 2218 deletions(-)

diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml @@ -10,9 +10,6 @@ permissions: jobs: release-please: runs-on: ubuntu-latest - outputs: - prs_created: ${{ steps.release.outputs.prs_created }} - pr_branch: ${{ steps.release.outputs.prs_created == 'true' && fromJSON(steps.release.outputs.pr).headBranchName || '' }} steps: - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 id: release @@ -20,23 +17,3 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} release-type: node - update-nix-hash: - runs-on: ubuntu-latest - needs: release-please - if: ${{ needs.release-please.outputs.prs_created == 'true' }} - steps: - - uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14 - - name: Update npmDepsHash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: ${{ needs.release-please.outputs.pr_branch }} - run: | - git clone --depth=1 --branch="$BRANCH" \ - "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" . - hash=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) - sed -i "s|npmDepsHash = \"sha256-[^\"]*\"|npmDepsHash = \"$hash\"|" flake.nix - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add flake.nix - git diff --staged --quiet || git commit -m "chore: update npmDepsHash" - git push diff --git a/.github/workflows/update-nix-hash.yml b/.github/workflows/update-nix-hash.yml @@ -0,0 +1,29 @@ +on: + push: + +permissions: + contents: write + +jobs: + update-nix-hash: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14 + + - name: Recompute npmDepsHash + run: | + hash=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) + sed -i "s|npmDepsHash = \"sha256-[^\"]*\"|npmDepsHash = \"$hash\"|" flake.nix + + - name: Commit if changed + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add flake.nix + git diff --staged --quiet || git commit -m "chore: update npmDepsHash" + git push diff --git a/README.md b/README.md @@ -75,7 +75,6 @@ npx spago run | `listenbrainzUser` | — | ListenBrainz username to sync scrobbles from | | `lastfmUser` | — | Last.fm username to sync scrobbles from | | `databaseFile` | `corpus.db` | Path to the user's DuckDB database file | -| `initialSync` | `false` | Perform a full historical sync on first run | | `coverCacheEnabled` | `true` | Enable cover art caching to S3 | | `backupEnabled` | `false` | Enable database backups to S3 | | `backupIntervalHours` | `24` | Backup frequency in hours | diff --git a/docs/architecture.md b/docs/architecture.md @@ -94,7 +94,6 @@ This prevents data corruption and ensures that any in-progress operations are co | `coverCacheEnabled` | `Bool` | Enable S3 cover art caching | | `backupEnabled` | `Bool` | Enable periodic S3 database backups | | `backupIntervalHours` | `Natural` | Backup frequency | -| `initialSync` | `Bool` | Paginate full history on first startup | ## Data Flow diff --git a/flake.nix b/flake.nix @@ -79,7 +79,7 @@ outputHashAlgo = "sha256"; outputHashMode = "recursive"; - outputHash = "sha256-hlMN1T0s8otmQcj/Hinf/FpLWSjAIesE3n3krGBS/WU="; + outputHash = "sha256-hUJb0OehrpmhnyQHJf1S9K96q+LoOLinDd8HlgHV+/E="; buildPhase = '' export HOME=$TMPDIR @@ -131,7 +131,7 @@ inherit ((builtins.fromJSON (builtins.readFile ./package.json))) version; inherit src; - npmDepsHash = "sha256-Qy2X2mWaQcF895PE3dXqoQj84fdywC7uiIxITuN/YpM="; + npmDepsHash = "sha256-A89/7mzFxURQQkgOR7li8vBZnCH2jqtBLxYuFOmsPbw="; npmRebuildFlags = ["--ignore-scripts"]; nativeBuildInputs = with pkgs; [ diff --git a/package-lock.json b/package-lock.json @@ -11,7 +11,8 @@ "@aws-sdk/client-s3": "^3.1029.0", "dotenv": "^17.4.1", "duckdb": "^1.4.4", - "prom-client": "^15.1.3" + "prom-client": "^15.1.3", + "sharp": "^0.34.5" }, "devDependencies": { "elm": "^0.19.1", @@ -876,6 +877,16 @@ "node": ">=18.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -1324,6 +1335,471 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "license": "MIT" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -5294,6 +5770,50 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json @@ -5,8 +5,8 @@ "main": "index.js", "type": "module", "scripts": { - "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client && elm make src/Client.elm --output=client.js", - "release": "npx spago build && npx purs-backend-es build && npx esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client && elm make src/Client.elm --optimize --output=client.js && npx uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | npx uglifyjs --mangle --output client.js", + "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp && elm make src/Client.elm --output=client.js", + "release": "npx spago build && npx purs-backend-es build && npx esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp && elm make src/Client.elm --optimize --output=client.js && npx uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | npx uglifyjs --mangle --output client.js", "test": "npx spago test", "tidy": "purs-tidy format-in-place src/**/*.purs" }, @@ -25,6 +25,7 @@ "@aws-sdk/client-s3": "^3.1029.0", "dotenv": "^17.4.1", "duckdb": "^1.4.4", - "prom-client": "^15.1.3" + "prom-client": "^15.1.3", + "sharp": "^0.34.5" } } diff --git a/spago.lock b/spago.lock @@ -6,6 +6,7 @@ "core": { "dependencies": [ "aff", + "aff-promise", "aff-retry", "argonaut", "argonaut-core", @@ -721,6 +722,21 @@ "unsafe-coerce" ] }, + "aff-promise": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-Jgp3y+NWuuAmwz2V3LlWoX+AvxqfVglY77N5zTw8xnI=", + "dependencies": [ + "aff", + "control", + "effect", + "either", + "exceptions", + "foreign", + "prelude", + "transformers" + ] + }, "aff-retry": { "type": "registry", "version": "2.0.0", diff --git a/spago.yaml b/spago.yaml @@ -2,6 +2,7 @@ package: name: corpus dependencies: - aff + - aff-promise - aff-retry - argonaut - argonaut-core diff --git a/src/Api.elm b/src/Api.elm @@ -0,0 +1,209 @@ +module Api exposing (fetchListens, fetchSectionData, fetchSimilarTracks, fetchStats, httpErrorToString, listensDecoder, sectionEntriesDecoder, similarTracksDecoder, statsDecoder, statsUrl) + +import Http +import Json.Decode as D exposing (Decoder) +import Types exposing (ActiveFilter, Listen, Msg(..), Period(..), SimilarTrack, Stats, StatsEntry) +import Url + + +fetchListens : String -> Int -> Int -> Maybe ActiveFilter -> Maybe String -> Cmd Msg +fetchListens userSlug limit offset mFilter mSearch = + let + filterParams = + case mFilter of + Nothing -> + "" + + Just { field, value } -> + "&filterField=" ++ field ++ "&filterValue=" ++ Url.percentEncode value + + searchParam = + case mSearch of + Nothing -> + "" + + Just q -> + "&search=" ++ Url.percentEncode q + + url = + "/proxy?user=" ++ userSlug ++ "&limit=" ++ String.fromInt limit ++ "&offset=" ++ String.fromInt offset ++ filterParams ++ searchParam + in + Http.get + { url = url + , expect = Http.expectJson GotListens listensDecoder + } + + +fetchStats : String -> Period -> Cmd Msg +fetchStats userSlug period = + Http.get + { url = statsUrl userSlug period Nothing + , expect = Http.expectJson GotStats statsDecoder + } + + +fetchSectionData : String -> Period -> String -> Cmd Msg +fetchSectionData userSlug period section = + Http.get + { url = statsUrl userSlug period (Just section) + , expect = Http.expectJson (GotSectionData section) (sectionEntriesDecoder section) + } + + +statsUrl : String -> Period -> Maybe String -> String +statsUrl userSlug period mSection = + let + periodPart = + case period of + AllTime -> + "" + + LastDays n -> + "&period=" ++ String.fromInt n + + CustomRange from to -> + "&from=" ++ from ++ "&to=" ++ to + + sectionPart = + case mSection of + Nothing -> + "" + + Just sec -> + "&section=" ++ sec + in + "/stats?user=" ++ userSlug ++ periodPart ++ sectionPart + + +fetchSimilarTracks : String -> String -> Cmd Msg +fetchSimilarTracks artist track = + Http.get + { url = "/similar?artist=" ++ Url.percentEncode artist ++ "&track=" ++ Url.percentEncode track + , expect = Http.expectJson (FetchSimilarTracks artist track) similarTracksDecoder + } + + +httpErrorToString : Http.Error -> String +httpErrorToString err = + case err of + Http.BadUrl url -> + "Bad URL: " ++ url + + Http.Timeout -> + "Request timed out" + + Http.NetworkError -> + "Network error" + + Http.BadStatus status -> + "Server error: " ++ String.fromInt status + + Http.BadBody body -> + "JSON decode error: " ++ body + + + +-- DECODERS + + +listensDecoder : Decoder (List Listen) +listensDecoder = + D.at [ "payload", "listens" ] (D.list listenDecoder) + + +listenDecoder : Decoder Listen +listenDecoder = + D.map2 + (\meta listenedAt -> + { trackName = meta.trackName + , artistName = meta.artistName + , releaseName = meta.releaseName + , listenedAt = listenedAt + , genre = meta.genre + , label = meta.label + , releaseMbid = meta.releaseMbid + , caaReleaseMbid = meta.caaReleaseMbid + } + ) + (D.field "track_metadata" trackMetaDecoder) + (D.maybe (D.field "listened_at" D.int)) + + +type alias TrackMeta = + { trackName : Maybe String + , artistName : Maybe String + , releaseName : Maybe String + , genre : Maybe String + , releaseMbid : Maybe String + , caaReleaseMbid : Maybe String + , label : Maybe String + } + + +trackMetaDecoder : Decoder TrackMeta +trackMetaDecoder = + D.map7 TrackMeta + (D.maybe (D.field "track_name" D.string)) + (D.maybe (D.field "artist_name" D.string)) + (D.maybe (D.field "release_name" D.string)) + (D.maybe (D.field "genre" D.string)) + (D.maybe (D.at [ "mbid_mapping", "release_mbid" ] D.string)) + (D.maybe (D.at [ "mbid_mapping", "caa_release_mbid" ] D.string)) + (D.maybe (D.field "label" D.string)) + + +statsDecoder : Decoder Stats +statsDecoder = + D.map5 Stats + (D.field "genres" (D.list entryDecoder)) + (D.field "labels" (D.list entryDecoder)) + (D.field "years" (D.list entryDecoder)) + (D.field "artists" (D.list entryDecoder)) + (D.field "tracks" (D.list entryDecoder)) + + +entryDecoder : Decoder StatsEntry +entryDecoder = + D.map2 StatsEntry + (D.field "name" D.string) + (D.field "count" D.int) + + +sectionEntriesDecoder : String -> Decoder (List StatsEntry) +sectionEntriesDecoder section = + D.map + (\stats -> + case section of + "artist" -> + stats.artists + + "track" -> + stats.tracks + + "genre" -> + stats.genres + + "label" -> + stats.labels + + "year" -> + stats.years + + _ -> + [] + ) + statsDecoder + + +similarTracksDecoder : Decoder (List SimilarTrack) +similarTracksDecoder = + D.at [ "data", "similar_tracks" ] (D.list similarTrackDecoder) + + +similarTrackDecoder : Decoder SimilarTrack +similarTrackDecoder = + D.map4 SimilarTrack + (D.field "artist" D.string) + (D.field "track" D.string) + (D.maybe (D.field "score" D.float)) + (D.maybe (D.field "video_uri" D.string)) diff --git a/src/Client.elm b/src/Client.elm @@ -1,1419 +1,18 @@ -port module Client exposing (main) +module Client exposing (main) import Browser -import Dict exposing (Dict) -import Html exposing (Html, a, button, div, h1, h2, img, input, li, p, span, strong, text, ul) -import Html.Attributes as Attr exposing (class, disabled, href, placeholder, src, style, target, type_, value) -import Html.Events exposing (on, onClick, onInput) -import Http -import Json.Decode as D exposing (Decoder) -import List -import Maybe -import Set exposing (Set) -import String -import Task -import Time -import Url - - -port pushUrl : String -> Cmd msg - - -type alias UserInfo = - { slug : String - , name : String - } - - -type alias Flags = - { search : String - , userSlug : String - , allUsers : List UserInfo - } +import State exposing (init, subscriptions, update) +import Types exposing (Flags, Model, Msg(..)) +import View exposing (view) main : Program Flags Model Msg main = - Browser.element + Browser.application { init = init + , onUrlRequest = UrlRequested + , onUrlChange = UrlChanged , update = update , view = view , subscriptions = subscriptions } - - - --- TYPES - - -type Tab - = ListensTab - | StatsTab - | AboutTab - - -type Period - = AllTime - | LastDays Int - | CustomRange String String - - -type alias ActiveFilter = - { field : String - , value : String - } - - -type alias Listen = - { trackName : Maybe String - , artistName : Maybe String - , releaseName : Maybe String - , listenedAt : Maybe Int - , genre : Maybe String - , label : Maybe String - , releaseMbid : Maybe String - , caaReleaseMbid : Maybe String - } - - -type alias StatsEntry = - { name : String - , count : Int - } - - -type alias Stats = - { genres : List StatsEntry - , labels : List StatsEntry - , years : List StatsEntry - , artists : List StatsEntry - , tracks : List StatsEntry - } - - -type alias SimilarTrack = - { artist : String - , track : String - , score : Maybe Float - , videoUri : Maybe String - } - - -type SimilarState - = SimilarLoading - | SimilarLoaded (List SimilarTrack) - | SimilarError String - - - --- MODEL - - -type alias Model = - { listens : List Listen - , stats : Maybe Stats - , error : Maybe String - , loading : Bool - , currentTime : Maybe Time.Posix - , failedCovers : Set String - , hoveredCover : Maybe Int - , expandedSections : Set String - , loadedSections : Set String - , offset : Int - , limit : Int - , activeTab : Tab - , activeFilter : Maybe ActiveFilter - , statsPeriod : Period - , customInput : String - , showCustomInput : Bool - , customError : Maybe String - , userSlug : String - , similarStates : Dict String SimilarState - , allUsers : List UserInfo - , searchInput : String - , activeSearch : Maybe String - } - - -init : Flags -> ( Model, Cmd Msg ) -init flags = - let - page = - parsePageParam flags.search - - offset = - max 0 ((page - 1) * 25) - in - ( { listens = [] - , stats = Nothing - , error = Nothing - , loading = True - , currentTime = Nothing - , failedCovers = Set.empty - , hoveredCover = Nothing - , expandedSections = Set.empty - , loadedSections = Set.empty - , offset = offset - , limit = 25 - , activeTab = ListensTab - , activeFilter = Nothing - , statsPeriod = AllTime - , customInput = "" - , showCustomInput = False - , customError = Nothing - , userSlug = flags.userSlug - , similarStates = Dict.empty - , allUsers = flags.allUsers - , searchInput = "" - , activeSearch = Nothing - } - , Cmd.batch - [ fetchListens flags.userSlug 25 offset Nothing Nothing - , Task.perform GotTime Time.now - ] - ) - - -parsePageParam : String -> Int -parsePageParam search = - let - s = - if String.startsWith "?" search then - String.dropLeft 1 search - - else - search - in - s - |> String.split "&" - |> List.filterMap - (\pair -> - case String.split "=" pair of - [ "page", v ] -> - String.toInt v - - _ -> - Nothing - ) - |> List.head - |> Maybe.withDefault 1 - - - --- MSG - - -type Msg - = GotListens (Result Http.Error (List Listen)) - | GotStats (Result Http.Error Stats) - | GotSectionData String (Result Http.Error (List StatsEntry)) - | Tick Time.Posix - | GotTime Time.Posix - | ImageError String - | NextPage - | PrevPage - | SwitchTab Tab - | SetStatsPeriod Period - | OpenCustomInput - | UpdateCustomInput String - | ApplyCustomPeriod - | FilterBy String String - | ClearFilter - | HoverCover Int - | UnhoverCover - | ExpandSection String - | ShowAllSection String - | CollapseSection String - | FetchSimilar String String - | FetchSimilarTracks String String (Result Http.Error (List SimilarTrack)) - | UpdateSearchInput String - | SubmitSearch - | ClearSearch - - - --- UPDATE - - -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = - case msg of - Tick time -> - if not model.loading then - ( { model | currentTime = Just time, loading = True } - , fetchListens model.userSlug model.limit model.offset model.activeFilter model.activeSearch - ) - - else - ( { model | currentTime = Just time }, Cmd.none ) - - GotTime time -> - ( { model | currentTime = Just time }, Cmd.none ) - - GotListens result -> - let - newModel = - case result of - Err err -> - { model | loading = False, error = Just (httpErrorToString err) } - - Ok listens -> - { model | loading = False, listens = listens, error = Nothing } - in - ( newModel, Task.perform GotTime Time.now ) - - GotStats result -> - case result of - Err err -> - ( { model | error = Just (httpErrorToString err) }, Cmd.none ) - - Ok stats -> - ( { model | stats = Just stats }, Cmd.none ) - - GotSectionData section result -> - case result of - Err err -> - ( { model | error = Just (httpErrorToString err) }, Cmd.none ) - - Ok entries -> - ( { model - | expandedSections = Set.insert section model.expandedSections - , loadedSections = Set.insert section model.loadedSections - , stats = Maybe.map (patchStatSection section entries) model.stats - } - , Cmd.none - ) - - ImageError url -> - ( { model | failedCovers = Set.insert url model.failedCovers }, Cmd.none ) - - NextPage -> - let - newOffset = - model.offset + model.limit - - page = - newOffset // model.limit + 1 - in - ( { model | offset = newOffset, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch - , pushUrl ("?page=" ++ String.fromInt page) - ] - ) - - PrevPage -> - let - newOffset = - max 0 (model.offset - model.limit) - - page = - newOffset // model.limit + 1 - in - ( { model | offset = newOffset, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch - , pushUrl ("?page=" ++ String.fromInt page) - ] - ) - - SwitchTab tab -> - ( { model | activeTab = tab } - , case tab of - StatsTab -> - if model.stats == Nothing then - fetchStats model.userSlug model.statsPeriod - - else - Cmd.none - - ListensTab -> - Cmd.none - - AboutTab -> - Cmd.none - ) - - SetStatsPeriod period -> - ( { model - | statsPeriod = period - , stats = Nothing - , expandedSections = Set.empty - , loadedSections = Set.empty - , showCustomInput = False - } - , fetchStats model.userSlug period - ) - - OpenCustomInput -> - let - prefill = - case model.statsPeriod of - CustomRange from to -> - from ++ " " ++ to - - _ -> - model.customInput - in - ( { model | showCustomInput = True, customInput = prefill, customError = Nothing } - , Cmd.none - ) - - UpdateCustomInput str -> - ( { model | customInput = str, customError = Nothing }, Cmd.none ) - - ApplyCustomPeriod -> - let - parts = - model.customInput - |> String.split " " - |> List.filter (not << String.isEmpty) - in - case parts of - [ from, to ] -> - if String.length from /= 10 || String.length to /= 10 then - ( { model | customError = Just "Dates must be in YYYY-MM-DD format" }, Cmd.none ) - - else if from > to then - ( { model | customError = Just "'from' must be before 'to'" }, Cmd.none ) - - else - let - period = - CustomRange from to - in - ( { model - | statsPeriod = period - , stats = Nothing - , expandedSections = Set.empty - , loadedSections = Set.empty - , showCustomInput = False - , customError = Nothing - } - , fetchStats model.userSlug period - ) - - _ -> - ( { model | customError = Just "Enter two dates separated by a space" }, Cmd.none ) - - FilterBy field value -> - let - filter = - Just { field = field, value = value } - in - ( { model | activeFilter = filter, activeSearch = Nothing, searchInput = "", offset = 0, activeTab = ListensTab, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit 0 filter Nothing - , pushUrl "?page=1" - ] - ) - - ClearFilter -> - ( { model | activeFilter = Nothing, offset = 0, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit 0 Nothing model.activeSearch - , pushUrl "?page=1" - ] - ) - - UpdateSearchInput str -> - ( { model | searchInput = str }, Cmd.none ) - - SubmitSearch -> - let - q = - String.trim model.searchInput - in - if String.isEmpty q then - ( { model | activeSearch = Nothing, offset = 0, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit 0 Nothing Nothing - , pushUrl "?page=1" - ] - ) - - else - ( { model | activeSearch = Just q, activeFilter = Nothing, offset = 0, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit 0 Nothing (Just q) - , pushUrl "?page=1" - ] - ) - - ClearSearch -> - ( { model | searchInput = "", activeSearch = Nothing, offset = 0, loading = True } - , Cmd.batch - [ fetchListens model.userSlug model.limit 0 Nothing Nothing - , pushUrl "?page=1" - ] - ) - - HoverCover idx -> - ( { model | hoveredCover = Just idx }, Cmd.none ) - - UnhoverCover -> - ( { model | hoveredCover = Nothing }, Cmd.none ) - - ExpandSection section -> - ( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none ) - - ShowAllSection section -> - ( model, fetchSectionData model.userSlug model.statsPeriod section ) - - CollapseSection section -> - ( { model - | expandedSections = Set.remove section model.expandedSections - , loadedSections = Set.remove section model.loadedSections - } - , Cmd.none - ) - - FetchSimilar artist track -> - let - key = - artist ++ "\t" ++ track - in - if Dict.member key model.similarStates then - ( { model | similarStates = Dict.remove key model.similarStates }, Cmd.none ) - - else - ( { model | similarStates = Dict.insert key SimilarLoading model.similarStates } - , fetchSimilarTracks artist track - ) - - FetchSimilarTracks artist track result -> - let - key = - artist ++ "\t" ++ track - - newStates = - case result of - Ok tracks -> - Dict.insert key (SimilarLoaded tracks) model.similarStates - - Err err -> - Dict.insert key (SimilarError (httpErrorToString err)) model.similarStates - in - ( { model | similarStates = newStates }, Cmd.none ) - - -patchStatSection : String -> List StatsEntry -> Stats -> Stats -patchStatSection section entries stats = - case section of - "artist" -> - { stats | artists = entries } - - "track" -> - { stats | tracks = entries } - - "genre" -> - { stats | genres = entries } - - "label" -> - { stats | labels = entries } - - "year" -> - { stats | years = entries } - - _ -> - stats - - - --- SUBSCRIPTIONS - - -subscriptions : Model -> Sub Msg -subscriptions _ = - Time.every 30000 Tick - - - --- HTTP - - -fetchListens : String -> Int -> Int -> Maybe ActiveFilter -> Maybe String -> Cmd Msg -fetchListens userSlug limit offset mFilter mSearch = - let - filterParams = - case mFilter of - Nothing -> - "" - - Just { field, value } -> - "&filterField=" ++ field ++ "&filterValue=" ++ Url.percentEncode value - - searchParam = - case mSearch of - Nothing -> - "" - - Just q -> - "&search=" ++ Url.percentEncode q - - url = - "/proxy?user=" ++ userSlug ++ "&limit=" ++ String.fromInt limit ++ "&offset=" ++ String.fromInt offset ++ filterParams ++ searchParam - in - Http.get - { url = url - , expect = Http.expectJson GotListens listensDecoder - } - - -fetchStats : String -> Period -> Cmd Msg -fetchStats userSlug period = - Http.get - { url = statsUrl userSlug period Nothing - , expect = Http.expectJson GotStats statsDecoder - } - - -fetchSectionData : String -> Period -> String -> Cmd Msg -fetchSectionData userSlug period section = - Http.get - { url = statsUrl userSlug period (Just section) - , expect = Http.expectJson (GotSectionData section) (sectionEntriesDecoder section) - } - - -statsUrl : String -> Period -> Maybe String -> String -statsUrl userSlug period mSection = - let - periodPart = - case period of - AllTime -> - "" - - LastDays n -> - "&period=" ++ String.fromInt n - - CustomRange from to -> - "&from=" ++ from ++ "&to=" ++ to - - sectionPart = - case mSection of - Nothing -> - "" - - Just sec -> - "&section=" ++ sec - in - "/stats?user=" ++ userSlug ++ periodPart ++ sectionPart - - -fetchSimilarTracks : String -> String -> Cmd Msg -fetchSimilarTracks artist track = - Http.get - { url = "/similar?artist=" ++ Url.percentEncode artist ++ "&track=" ++ Url.percentEncode track - , expect = Http.expectJson (FetchSimilarTracks artist track) similarTracksDecoder - } - - -httpErrorToString : Http.Error -> String -httpErrorToString err = - case err of - Http.BadUrl url -> - "Bad URL: " ++ url - - Http.Timeout -> - "Request timed out" - - Http.NetworkError -> - "Network error" - - Http.BadStatus status -> - "Server error: " ++ String.fromInt status - - Http.BadBody body -> - "JSON decode error: " ++ body - - - --- DECODERS - - -listensDecoder : Decoder (List Listen) -listensDecoder = - D.at [ "payload", "listens" ] (D.list listenDecoder) - - -listenDecoder : Decoder Listen -listenDecoder = - D.map2 - (\meta listenedAt -> - { trackName = meta.trackName - , artistName = meta.artistName - , releaseName = meta.releaseName - , listenedAt = listenedAt - , genre = meta.genre - , label = meta.label - , releaseMbid = meta.releaseMbid - , caaReleaseMbid = meta.caaReleaseMbid - } - ) - (D.field "track_metadata" trackMetaDecoder) - (D.maybe (D.field "listened_at" D.int)) - - -type alias TrackMeta = - { trackName : Maybe String - , artistName : Maybe String - , releaseName : Maybe String - , genre : Maybe String - , releaseMbid : Maybe String - , caaReleaseMbid : Maybe String - , label : Maybe String - } - - -trackMetaDecoder : Decoder TrackMeta -trackMetaDecoder = - D.map7 TrackMeta - (D.maybe (D.field "track_name" D.string)) - (D.maybe (D.field "artist_name" D.string)) - (D.maybe (D.field "release_name" D.string)) - (D.maybe (D.field "genre" D.string)) - (D.maybe (D.at [ "mbid_mapping", "release_mbid" ] D.string)) - (D.maybe (D.at [ "mbid_mapping", "caa_release_mbid" ] D.string)) - (D.maybe (D.field "label" D.string)) - - -statsDecoder : Decoder Stats -statsDecoder = - D.map5 Stats - (D.field "genres" (D.list entryDecoder)) - (D.field "labels" (D.list entryDecoder)) - (D.field "years" (D.list entryDecoder)) - (D.field "artists" (D.list entryDecoder)) - (D.field "tracks" (D.list entryDecoder)) - - -entryDecoder : Decoder StatsEntry -entryDecoder = - D.map2 StatsEntry - (D.field "name" D.string) - (D.field "count" D.int) - - -sectionEntriesDecoder : String -> Decoder (List StatsEntry) -sectionEntriesDecoder section = - D.map - (\stats -> - case section of - "artist" -> - stats.artists - - "track" -> - stats.tracks - - "genre" -> - stats.genres - - "label" -> - stats.labels - - "year" -> - stats.years - - _ -> - [] - ) - statsDecoder - - -similarTracksDecoder : Decoder (List SimilarTrack) -similarTracksDecoder = - D.at [ "data", "similar_tracks" ] (D.list similarTrackDecoder) - - -similarTrackDecoder : Decoder SimilarTrack -similarTrackDecoder = - D.map4 SimilarTrack - (D.field "artist" D.string) - (D.field "track" D.string) - (D.maybe (D.field "score" D.float)) - (D.maybe (D.field "video_uri" D.string)) - - - --- VIEW - - -view : Model -> Html Msg -view model = - div [ class "container" ] - [ h1 [] [ text "scrobbler" ] - , div [ class "tabs" ] - [ a - [ class - ("tab-btn" - ++ (if model.activeTab == ListensTab then - " active" - - else - "" - ) - ) - , href - (if model.userSlug == "" then - "/" - - else - "/u/" ++ model.userSlug - ) - ] - [ text "listens" ] - , button - [ class - ("tab-btn" - ++ (if model.activeTab == StatsTab then - " active" - - else - "" - ) - ) - , onClick (SwitchTab StatsTab) - ] - [ text "stats" ] - , button - [ class - ("tab-btn" - ++ (if model.activeTab == AboutTab then - " active" - - else - "" - ) - ) - , onClick (SwitchTab AboutTab) - ] - [ text "about" ] - ] - , case model.activeTab of - ListensTab -> - div [] - [ div [ class "search-bar" ] - [ input - [ type_ "text" - , class "search-input" - , placeholder "search by track, artist, album or label" - , value model.searchInput - , onInput UpdateSearchInput - , onEnter SubmitSearch - ] - [] - , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ] - , case model.activeSearch of - Just _ -> - button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ] - - Nothing -> - text "" - ] - , case model.activeFilter of - Nothing -> - text "" - - Just { field, value } -> - div [ class "filter-banner" ] - [ span [ class "filter-label" ] - [ text (field ++ ": ") - , strong [] [ text value ] - ] - , button [ class "filter-clear", onClick ClearFilter ] - [ text "✕ clear" ] - ] - , renderContent model - , div [ class "pagination" ] - [ button - [ class "page-btn" - , disabled (model.offset == 0 || model.loading) - , onClick PrevPage - ] - [ text "Previous" ] - , div [ class "page-indicator" ] - [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ] - , button - [ class "page-btn" - , disabled (List.length model.listens < model.limit || model.loading) - , onClick NextPage - ] - [ text "Next" ] - ] - ] - - StatsTab -> - div [] - [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError - , renderStatsView model.expandedSections model.loadedSections model.stats - ] - - AboutTab -> - renderAboutView model.userSlug model.allUsers - ] - - -renderContent : Model -> Html Msg -renderContent model = - if model.loading && List.isEmpty model.listens then - ul [] [ li [ class "loading" ] [ text "⏳" ] ] - - else - case model.error of - Just err -> - ul [] [ li [ class "error" ] [ text err ] ] - - Nothing -> - div [ class "tracks-with-similar" ] - (List.indexedMap - (\idx listen -> - let - artist = - Maybe.withDefault "" listen.artistName - - trackName = - Maybe.withDefault "" listen.trackName - - key = - artist ++ "\t" ++ trackName - in - div [ class "track-with-similar-container" ] - [ ul [ class "track-item" ] - [ renderListen model.userSlug model.currentTime model.failedCovers model.hoveredCover model.similarStates idx listen ] - , renderSimilarPanel model.similarStates key - ] - ) - model.listens - ) - - -renderListen : String -> Maybe Time.Posix -> Set String -> Maybe Int -> Dict String SimilarState -> Int -> Listen -> Html Msg -renderListen userSlug currentTime failedCovers hoveredCover similarStates idx listen = - let - artist = - Maybe.withDefault "" listen.artistName - - trackName = - Maybe.withDefault "" listen.trackName - - release = - Maybe.withDefault "" listen.releaseName - - key = - artist ++ "\t" ++ trackName - - mbid = - case listen.caaReleaseMbid of - Just m -> - Just m - - Nothing -> - listen.releaseMbid - - coverUrl = - "/cover?user=" - ++ userSlug - ++ "&artist=" - ++ Url.percentEncode artist - ++ "&release=" - ++ Url.percentEncode release - ++ (case mbid of - Just m -> - "&mbid=" ++ m - - Nothing -> - "" - ) - - isZoomed = - hoveredCover == Just idx - - isActive = - Dict.member key similarStates - in - li [ class "success" ] - [ div [ class "track-info" ] - [ div [ class "track-name" ] - [ button [ class "track-link", onClick (FilterBy "track" (Maybe.withDefault "Unknown Track" listen.trackName)) ] [ text (Maybe.withDefault "Unknown Track" listen.trackName) ] ] - , div [ class "track-artist" ] [ button [ class "artist-link", onClick (FilterBy "artist" artist) ] [ text artist ] ] - , div [ class "track-time" ] - [ span [] - (button [ class "album-link", onClick (FilterBy "album" release) ] [ text release ] - :: (case listen.label of - Just l -> - [ text " • " - , button [ class "label-link", onClick (FilterBy "label" l) ] [ text l ] - ] - - Nothing -> - [] - ) - ++ [ text (" • " ++ timeAgo currentTime listen.listenedAt) ] - ) - ] - ] - , div [ class "cover-wrapper" ] - [ if Set.member coverUrl failedCovers then - text "" - - else - img - [ class - ("track-cover" - ++ (if isZoomed then - " zoomed" - - else - "" - ) - ) - , src coverUrl - , Attr.alt release - , on "error" (D.succeed (ImageError coverUrl)) - , onClick - (if isZoomed then - UnhoverCover - - else - HoverCover idx - ) - ] - [] - , case listen.genre of - Just g -> - div [ class "genre-tag" ] [ text g ] - - Nothing -> - text "" - , if artist /= "" && trackName /= "" then - button - [ class - ("similar-btn" - ++ (if isActive then - " active" - - else - "" - ) - ) - , onClick (FetchSimilar artist trackName) - ] - [ text "similar" ] - - else - text "" - ] - ] - - -renderSimilarPanel : Dict String SimilarState -> String -> Html Msg -renderSimilarPanel similarStates key = - case Dict.get key similarStates of - Nothing -> - text "" - - Just SimilarLoading -> - div [ class "similar-panel" ] - [ div [ class "similar-loading" ] [ text "⏳" ] ] - - Just (SimilarError err) -> - div [ class "similar-panel" ] - [ div [ class "similar-error" ] [ text err ] ] - - Just (SimilarLoaded tracks) -> - div [ class "similar-panel" ] - [ if List.isEmpty tracks then - div [ class "similar-empty" ] [ text "no similar songs found" ] - - else - div [] (List.map renderSimilarTrack tracks) - ] - - -renderSimilarTrack : SimilarTrack -> Html Msg -renderSimilarTrack track = - div [ class "similar-track" ] - [ div [ class "similar-track-info" ] - [ div [ class "similar-track-name" ] [ text track.track ] - , div [ class "similar-track-artist" ] [ text track.artist ] - ] - , case track.score of - Just score -> - span [ class "similar-score" ] - [ text (String.fromInt (round (score * 100)) ++ "%") ] - - Nothing -> - text "" - , case track.videoUri of - Just link -> - a [ class "similar-link", href link, target "_blank" ] - [ text "▶" ] - - Nothing -> - text "" - ] - - -renderStatsView : Set String -> Set String -> Maybe Stats -> Html Msg -renderStatsView expandedSections loadedSections mStats = - case mStats of - Nothing -> - div [ class "loading" ] [ text "⏳" ] - - Just stats -> - div [] - [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" stats.artists - , renderStatSection expandedSections loadedSections Nothing "top tracks" stats.tracks - , renderStatSection expandedSections loadedSections (Just "genre") "genres" stats.genres - , renderStatSection expandedSections loadedSections (Just "label") "labels" stats.labels - , renderStatSection expandedSections loadedSections (Just "year") "years" stats.years - ] - - -renderStatSection : Set String -> Set String -> Maybe String -> String -> List StatsEntry -> Html Msg -renderStatSection expandedSections loadedSections mField title entries = - let - sectionKey = - Maybe.withDefault title mField - - expanded = - Set.member sectionKey expandedSections - - loaded = - Set.member sectionKey loadedSections - - visible = - if loaded then - entries - - else if expanded then - List.take 50 entries - - else - List.take 10 entries - - maxCount = - entries - |> List.map .count - |> List.maximum - |> Maybe.withDefault 1 - - n = - List.length entries - - footer = - if loaded then - if n > 10 then - button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] - - else - text "" - - else if expanded then - if n >= 50 then - span [] - [ button [ class "show-all-btn", onClick (ShowAllSection sectionKey) ] [ text "show all" ] - , text " · " - , button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] - ] - - else - button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] - - else if n > 10 then - button [ class "show-all-btn", onClick (ExpandSection sectionKey) ] [ text "show more" ] - - else - text "" - in - div [ class "stats-section" ] - [ h2 [] [ text title ] - , if List.isEmpty entries then - div [ class "stats-empty" ] [ text "beyond here lies nothing" ] - - else - ul [] (List.map (renderStatEntry maxCount mField) visible) - , footer - ] - - -renderStatEntry : Int -> Maybe String -> StatsEntry -> Html Msg -renderStatEntry maxCount mField entry = - let - barPct = - entry.count * 100 // maxCount - - rowAttrs = - case mField of - Just field -> - [ class "stat-row clickable", onClick (FilterBy field entry.name) ] - - Nothing -> - [ class "stat-row" ] - in - li rowAttrs - [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] [] - , span [ class "stat-name" ] [ text entry.name ] - , span [ class "stat-count" ] [ text (String.fromInt entry.count) ] - ] - - -renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Html Msg -renderPeriodSelector current showInput customVal mError = - let - isCustom = - case current of - CustomRange _ _ -> - True - - _ -> - False - - namedBtn target label = - button - [ class - ("period-btn" - ++ (if current == target then - " active" - - else - "" - ) - ) - , onClick (SetStatsPeriod target) - ] - [ text label ] - - customBtn = - button - [ class - ("period-btn" - ++ (if isCustom then - " active" - - else - "" - ) - ) - , onClick OpenCustomInput - ] - [ text "custom" ] - - daysBtn n = - let - label = - case n of - 7 -> - "1w" - - 14 -> - "2w" - - 30 -> - "1m" - - 90 -> - "3m" - - 180 -> - "6m" - - 365 -> - "1y" - - _ -> - String.fromInt n ++ "d" - in - button - [ class - ("period-btn" - ++ (if current == LastDays n then - " active" - - else - "" - ) - ) - , onClick (SetStatsPeriod (LastDays n)) - ] - [ text label ] - in - div [] - [ div [ class "period-selector" ] - ([ namedBtn AllTime "all time", customBtn ] - ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] - ) - , if showInput || isCustom then - div [] - [ div [ class "custom-range" ] - [ input - [ type_ "text" - , class - ("custom-range-input" - ++ (if mError /= Nothing then - " error" - - else - "" - ) - ) - , placeholder "2023-01-01 2026-01-01" - , value customVal - , onInput UpdateCustomInput - ] - [] - , button [ class "period-btn", onClick ApplyCustomPeriod ] - [ text "apply" ] - ] - , case mError of - Just err -> - div [ class "custom-range-error" ] [ text err ] - - Nothing -> - text "" - ] - - else - text "" - ] - - -onEnter : msg -> Html.Attribute msg -onEnter msg = - on "keydown" - (D.field "key" D.string - |> D.andThen - (\key -> - if key == "Enter" then - D.succeed msg - - else - D.fail "not enter" - ) - ) - - - --- HELPERS - - -timeAgo : Maybe Time.Posix -> Maybe Int -> String -timeAgo mNow mTimestamp = - case ( mNow, mTimestamp ) of - ( Just now, Just ts ) -> - let - nowSecs = - Time.posixToMillis now // 1000 - - diff = - nowSecs - ts - in - if diff < 60 then - "just now" - - else if diff < 3600 then - let - mins = - diff // 60 - in - String.fromInt mins - ++ " minute" - ++ (if mins > 1 then - "s" - - else - "" - ) - ++ " ago" - - else if diff < 86400 then - let - hours = - diff // 3600 - in - String.fromInt hours - ++ " hour" - ++ (if hours > 1 then - "s" - - else - "" - ) - ++ " ago" - - else - let - days = - diff // 86400 - in - String.fromInt days - ++ " day" - ++ (if days > 1 then - "s" - - else - "" - ) - ++ " ago" - - _ -> - "unknown time" - - -renderAboutView : String -> List UserInfo -> Html Msg -renderAboutView currentSlug allUsers = - let - otherUsers = - List.filter (\u -> u.slug /= currentSlug) allUsers - - userLink { slug, name } = - let - url = - if slug == "" then - "/" - - else - "/u/" ++ slug - in - li [] [ a [ href url ] [ text name ] ] - - extLink url label = - a [ href url, target "_blank", class "about-link" ] [ text label ] - in - div [] - [ p [ class "about-lead" ] - [ text "corpus is a self-hosted listen history proxy that syncs scrobbles from " - , extLink "https://listenbrainz.org" "ListenBrainz" - , text " and " - , extLink "https://www.last.fm" "Last.fm." - ] - , div [ class "stats-section" ] - [ h2 [] [ text "features" ] - , ul [ class "about-list" ] - [ li [] [ text "searchable listen history with pagination" ] - , li [] [ text "stats by artist, track, label, year, and genre" ] - , li [] [ text "filter listens by label, artist, genre, or year" ] - , li [] [ text "cover art from Cover Art Archive, Last.fm, and Discogs" ] - , li [] [ text "similar track discovery via cosine.club" ] - , li [] [ text "metadata enrichment via MusicBrainz, Last.fm, and Discogs" ] - ] - ] - , div [ class "stats-section" ] - [ h2 [] [ text "links" ] - , p [ class "about-meta" ] - [ div [] [ extLink "https://instagram.com/counterpoint303" "counterpoint" ] - , div [] [ extLink "https://github.com/mtmn/corpus" "github repo" ] - , div [] [ extLink "https://mtmn.name" "mtmn.name" ] - ] - ] - , if List.isEmpty otherUsers then - text "" - - else - div [ class "stats-section" ] - [ h2 [] [ text "friends" ] - , ul [ class "about-users" ] - (List.map userLink otherUsers) - ] - ] diff --git a/src/Config.purs b/src/Config.purs @@ -33,7 +33,6 @@ type UserConfig = , coverCacheEnabled :: Boolean , backupEnabled :: Boolean , backupIntervalHours :: Int - , initialSync :: Boolean } type UserEntry = @@ -172,7 +171,6 @@ decodeUserConfig json = do coverCacheEnabled <- mapLeft show $ obj .: "coverCacheEnabled" backupEnabled <- mapLeft show $ obj .: "backupEnabled" backupIntervalHours <- mapLeft show $ obj .: "backupIntervalHours" - initialSync <- mapLeft show $ obj .: "initialSync" pure { listenbrainzUser , lastfmUser @@ -189,5 +187,4 @@ decodeUserConfig json = do , coverCacheEnabled , backupEnabled , backupIntervalHours - , initialSync } diff --git a/src/Cosine.purs b/src/Cosine.purs @@ -0,0 +1,126 @@ +module Cosine + ( fetchCosineSimilar + , serveSimilar + ) where + +import Prelude + +import Config (UserConfig) +import Control.Monad.Error.Class (throwError) +import Data.Array ((!!)) +import Data.Either (Either(..), hush) +import Data.Argonaut (parseJson) +import Data.Argonaut.Core (toArray, toObject, toString) +import Data.Maybe (Maybe(..), fromMaybe) +import Effect (Effect) +import Effect.Aff (Aff, launchAff_, try) +import Effect.Class (liftEffect) +import Effect.Exception as Exception +import Fetch (fetch, Method(GET)) +import Foreign.Object as Object +import JSURI (encodeURIComponent) +import Log as Log +import Metrics as Metrics +import Node.Encoding (Encoding(UTF8)) +import Node.HTTP.OutgoingMessage (setHeader, toWriteable) +import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) +import Node.HTTP.Types (ServerResponse) +import Node.Stream (end, writeString) +import Web.URL (URL) +import Web.URL.URLSearchParams as URLSearchParams +import Web.URL as URL + +type Response = ServerResponse + +getQueryParam :: String -> URL -> Maybe String +getQueryParam key url = URLSearchParams.get key (URL.searchParams url) + +fetchCosineSimilar :: String -> UserConfig -> String -> Aff String +fetchCosineSimilar slug cfg query = do + let apiKey = fromMaybe "" cfg.cosineApiKey + if apiKey == "" then do + Log.warn "cosineApiKey not configured for similar tracks" + liftEffect $ Metrics.incCosineRequest slug "not_configured" + pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" + else do + let headers = { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Bearer " <> apiKey } + let searchUrl = "https://cosine.club/api/v1/search?q=" <> (fromMaybe "" $ encodeURIComponent query) <> "&limit=1" + Log.info $ "Cosine Club: searching for: " <> query + searchResult <- try $ fetch searchUrl { method: GET, headers: headers } + case searchResult of + Left err -> do + liftEffect $ Metrics.incCosineRequest slug "error" + throwError err + Right searchRes -> + if searchRes.status == 429 then do + Log.warn "Cosine Club: rate limited on search" + liftEffect $ Metrics.incCosineRequest slug "rate_limited" + throwError (Exception.error "Rate limit exceeded") + else if searchRes.status /= 200 then do + Log.warn $ "Cosine Club: search returned " <> show searchRes.status + liftEffect $ Metrics.incCosineRequest slug "error" + throwError (Exception.error "Search API error") + else do + searchBody <- searchRes.text + let + mTrackId = do + json <- hush $ parseJson searchBody + obj <- toObject json + arr <- Object.lookup "data" obj >>= toArray + first <- arr !! 0 + firstObj <- toObject first + Object.lookup "id" firstObj >>= toString + case mTrackId of + Nothing -> do + Log.info $ "Cosine Club: track not indexed: " <> query + liftEffect $ Metrics.incCosineRequest slug "not_indexed" + pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" + Just trackId -> do + let similarUrl = "https://cosine.club/api/v1/tracks/" <> trackId <> "/similar?limit=10" + Log.info $ "Cosine Club: fetching similar for ID " <> trackId + similarResult <- try $ fetch similarUrl { method: GET, headers: headers } + case similarResult of + Left err -> do + liftEffect $ Metrics.incCosineRequest slug "error" + throwError err + Right similarRes -> + if similarRes.status == 429 then do + Log.warn "Cosine Club: rate limited on similar" + liftEffect $ Metrics.incCosineRequest slug "rate_limited" + throwError (Exception.error "Rate limit exceeded") + else if similarRes.status /= 200 then do + Log.warn $ "Cosine Club: similar API returned " <> show similarRes.status + liftEffect $ Metrics.incCosineRequest slug "error" + throwError (Exception.error "Similar API error") + else do + liftEffect $ Metrics.incCosineRequest slug "success" + similarRes.text + +serveSimilar + :: (Response -> String -> Effect Unit) + -> (Response -> Int -> String -> String -> Effect Unit) + -> String + -> UserConfig + -> URL + -> Response + -> Effect Unit +serveSimilar serveBadRequest serveError slug cfg url res = do + setHeader "Content-Type" "application/json" (toOutgoingMessage res) + launchAff_ do + let artist = fromMaybe "" (getQueryParam "artist" url) + let track = fromMaybe "" (getQueryParam "track" url) + if artist == "" || track == "" then + liftEffect $ serveBadRequest res "Artist and track parameters are required" + else do + let query = artist <> " - " <> track + result <- try $ fetchCosineSimilar slug cfg query + case result of + Left err -> do + Log.error $ "Cosine Club API error: " <> Exception.message err + liftEffect $ serveError res 502 "Bad Gateway" "Failed to fetch similar tracks" + Right responseBody -> + liftEffect $ do + setStatusCode 200 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 responseBody + end w diff --git a/src/Cover.purs b/src/Cover.purs @@ -0,0 +1,207 @@ +module Cover + ( CoverSource + , sanitizeKey + , fetchLastfmCoverUrl + , fetchDiscogsCoverUrl + , coverSources + , serveCover + ) where + +import Prelude + +import Config (S3Config, UserConfig, s3ConfigFromUser) +import S3 (existsInS3, getS3Url, uploadToS3) +import Control.Monad.Error.Class (throwError) +import Data.Array ((!!)) +import Data.Either (Either(..)) +import Data.Foldable (foldM) +import Data.Map as Map +import Data.Maybe (Maybe(..), fromMaybe) +import Data.String.CaseInsensitive (CaseInsensitiveString(..)) +import Data.String.Regex (replace, parseFlags) +import Data.String.Regex.Unsafe (unsafeRegex) +import Effect (Effect) +import Effect.Aff (Aff, forkAff, launchAff_, try) +import Effect.Class (liftEffect) +import Effect.Exception as Exception +import Fetch (fetch, Method(GET)) +import Fetch.Argonaut.Json (fromJson) +import Foreign.Object as Object +import Data.Argonaut.Core (toArray, toObject, toString) +import Image (convertToAvif) +import JSURI (encodeURIComponent) +import Log as Log +import Metrics as Metrics +import Node.Buffer (fromArrayBuffer) +import Node.HTTP.OutgoingMessage (setHeader, toWriteable) +import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) +import Node.HTTP.Types (ServerResponse) +import Node.Stream (end, write) +import S3 (existsInS3, getS3Url, uploadToS3) +import Unsafe.Coerce (unsafeCoerce) +import Web.URL (URL) +import Web.URL as URL +import Web.URL.URLSearchParams as URLSearchParams + +type Response = ServerResponse + +type CoverSource = + { name :: String + , s3Key :: String + , findUrl :: Aff (Maybe String) + } + +sanitizeKey :: String -> String +sanitizeKey = replace re1 "_" >>> replace re2 "_" + where + re1 = unsafeRegex "[^a-z0-9.-]" (parseFlags "gi") + re2 = unsafeRegex "_{2,}" (parseFlags "g") + +getQueryParam :: String -> URL -> Maybe String +getQueryParam key url = URLSearchParams.get key (URL.searchParams url) + +fetchLastfmCoverUrl :: UserConfig -> String -> String -> Aff (Maybe String) +fetchLastfmCoverUrl cfg artist release = case cfg.lastfmApiKey of + Nothing -> + pure Nothing + Just k -> do + let + searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&artist=" + <> (fromMaybe "" $ encodeURIComponent artist) + <> "&album=" + <> (fromMaybe "" $ encodeURIComponent release) + <> "&format=json&api_key=" + <> k + Log.info $ "Searching Last.fm for: " <> artist <> " - " <> release + result <- try $ fetch searchUrl { method: GET } + case result of + Right fr | fr.status == 200 -> do + json <- fromJson fr.json + pure $ do + obj <- toObject json + album <- Object.lookup "album" obj >>= toObject + images <- Object.lookup "image" album >>= toArray + imgObj <- images !! 2 >>= toObject + u <- Object.lookup "#text" imgObj >>= toString + if u == "" then Nothing else Just u + _ -> + pure Nothing + +fetchDiscogsCoverUrl :: UserConfig -> String -> String -> Aff (Maybe String) +fetchDiscogsCoverUrl cfg artist release = case cfg.discogsToken of + Nothing -> + pure Nothing + Just t -> do + let + queryStr = artist <> " " <> release + searchUrl = "https://api.discogs.com/database/search?q=" + <> (fromMaybe "" $ encodeURIComponent queryStr) + <> "&type=release&per_page=1" + Log.info $ "Searching Discogs for: " <> queryStr + result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Discogs token=" <> t } } + case result of + Right fr | fr.status == 200 -> do + json <- fromJson fr.json + pure $ do + obj <- toObject json + results <- Object.lookup "results" obj >>= toArray + firstResult <- results !! 0 >>= toObject + Object.lookup "cover_image" firstResult >>= toString + _ -> + pure Nothing + +coverSources :: String -> String -> String -> UserConfig -> Array CoverSource +coverSources mbid artist release cfg = + let + safeArtist = sanitizeKey artist + safeRelease = sanitizeKey release + in + [ { name: "caa" + , s3Key: "covers/caa/" <> sanitizeKey mbid <> ".avif" + , findUrl: + if mbid == "" then pure Nothing + else pure $ Just $ "https://coverartarchive.org/release/" <> mbid <> "/front-250" + } + , { name: "lastfm" + , s3Key: "covers/lastfm/" <> safeArtist <> "-" <> safeRelease <> ".avif" + , findUrl: + if artist == "" || release == "" then pure Nothing + else fetchLastfmCoverUrl cfg artist release + } + , { name: "discogs" + , s3Key: "covers/discogs/" <> safeArtist <> "-" <> safeRelease <> ".avif" + , findUrl: + if artist == "" || release == "" then pure Nothing + else fetchDiscogsCoverUrl cfg artist release + } + ] + +serveCover :: (Response -> Effect Unit) -> UserConfig -> String -> URL -> Response -> Aff Unit +serveCover serveNotFound cfg slug url res = do + let + mbid = fromMaybe "" (getQueryParam "mbid" url) + artist = fromMaybe "" (getQueryParam "artist" url) + release = fromMaybe "" (getQueryParam "release" url) + s3cfg = s3ConfigFromUser cfg + + served <- foldM (trySource s3cfg) false (coverSources mbid artist release cfg) + unless served $ liftEffect $ serveNotFound res + + where + trySource _ true _ = pure true + trySource s3cfg false { name, s3Key, findUrl } = do + cached <- checkS3 s3cfg s3Key + if cached then do + Log.info $ "Serving " <> name <> " cover from S3: " <> s3Key + liftEffect $ Metrics.incCoverRequest slug name "s3_hit" + liftEffect $ serveS3Redirect s3cfg s3Key res + pure true + else do + mUrl <- findUrl + case mUrl of + Nothing -> + pure false + Just urlStr -> do + redirectAndCache s3cfg urlStr s3Key res + liftEffect $ Metrics.incCoverRequest slug name "fetch" + pure true + + checkS3 s3cfg s3Key + | not cfg.coverCacheEnabled = pure false + | otherwise = do + result <- try $ existsInS3 s3cfg s3Key + pure $ case result of + Right b -> b + Left _ -> false + + serveS3Redirect s3cfg s3Key response = do + setStatusCode 302 response + setHeader "Location" (getS3Url s3cfg s3Key) (toOutgoingMessage response) + end (toWriteable (toOutgoingMessage response)) + + -- Redirect the client immediately to the upstream URL, then fetch+convert+cache in background. + -- This avoids blocking the response on AVIF conversion (which can take hundreds of ms). + redirectAndCache s3cfg urlStr s3Key response = do + liftEffect $ do + setStatusCode 302 response + setHeader "Location" urlStr (toOutgoingMessage response) + setHeader "Cache-Control" "public, max-age=3600" (toOutgoingMessage response) + end (toWriteable (toOutgoingMessage response)) + when cfg.coverCacheEnabled $ void $ forkAff do + fetchResult <- try $ fetch urlStr { method: GET } + case fetchResult of + Right fr | fr.status == 200 -> do + let + isAvif = case Map.lookup (CaseInsensitiveString "content-type") fr.headers of + Just ct | ct == "image/avif" -> true + _ -> false + Log.info $ "caching image (" <> (if isAvif then "already avif" else "needs conversion") <> "): " <> urlStr + buf <- fr.arrayBuffer + avifBuf <- if isAvif then pure buf else convertToAvif buf + uploadResult <- try $ uploadToS3 s3cfg s3Key (unsafeCoerce avifBuf) "image/avif" + case uploadResult of + Right _ -> Log.info $ "Cached to S3: " <> s3Key + Left err -> Log.error $ "S3 upload failed: " <> Exception.message err + _ -> + Log.warn $ "Background fetch failed for: " <> urlStr + diff --git a/src/Image.js b/src/Image.js @@ -0,0 +1,11 @@ +import sharp from 'sharp'; + +export const convertToAvifImpl = (buffer) => () => + new Promise((resolve, reject) => + sharp(buffer) + .avif() + .toBuffer((err, data) => { + if (err) return reject(err); + resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); + }) + ); diff --git a/src/Image.purs b/src/Image.purs @@ -0,0 +1,12 @@ +module Image (convertToAvif) where + +import Prelude +import Control.Promise (Promise, toAffE) +import Effect (Effect) +import Effect.Aff (Aff) +import Data.ArrayBuffer.Types (ArrayBuffer) + +foreign import convertToAvifImpl :: ArrayBuffer -> Effect (Promise ArrayBuffer) + +convertToAvif :: ArrayBuffer -> Aff ArrayBuffer +convertToAvif = toAffE <<< convertToAvifImpl diff --git a/src/Main.purs b/src/Main.purs @@ -3,56 +3,47 @@ module Main where import Prelude import Config (AppConfig, UserConfig, UserEntry, loadConfig, s3ConfigFromUser) +import Control.Monad.Rec.Class (forever) +import Cover (serveCover) +import Cosine (serveSimilar) +import Data.Array (find, length) +import Data.Either (Either(..)) +import Data.Foldable (for_, traverse_) +import Data.Int (fromString, toNumber) +import Data.Maybe (Maybe(..), fromMaybe) +import Data.String (Pattern(..), stripPrefix) +import Data.String.Regex (replace, parseFlags) +import Data.String.Regex.Unsafe (unsafeRegex) +import Data.Traversable (traverse) +import Data.Tuple (Tuple(..)) +import Db (Connection, FilterField(..), backupDb, connect, getScrobbles, getStats, initDb, initReleaseMetadata, ping, withTransaction) import Effect (Effect) -import Node.EventEmitter (on_, EventHandle(..)) +import Effect.Aff (Aff, launchAff_, try, delay, forkAff, joinFiber, killFiber, Fiber) +import Effect.Aff.AVar (AVar) +import Effect.Aff.AVar as Avar import Effect.Class (liftEffect) +import Effect.Exception as Exception import Log as Log +import Node.EventEmitter (on_) +import Metadata (enrichMetadata) import Metrics as Metrics +import Node.Encoding (Encoding(UTF8)) +import Node.FS.Aff as FSA import Node.HTTP (createServer) -import Node.HTTPS as HTTPS -import Node.HTTP.Server as Server -import Effect.Uncurried (mkEffectFn1) -import Node.HTTP.ClientRequest as Client import Node.HTTP.IncomingMessage as IM -import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) -import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) import Node.HTTP.OutgoingMessage (setHeader, toWriteable) -import Node.Stream (end, write, writeString) -import Node.Stream.Aff (readableToStringUtf8) -import Node.Encoding (Encoding(UTF8)) +import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) +import Node.HTTP.Server as Server +import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) import Node.Net.Server (listenTcp, listeningH) -import Node.Buffer (fromArrayBuffer) -import Data.Either (Either(..), hush) -import Control.Monad.Error.Class (throwError) -import Effect.Exception as Exception -import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try, delay, forkAff, joinFiber, killFiber, Fiber) -import Effect.Aff.AVar (AVar) -import Effect.Aff.AVar as Avar -import Unsafe.Coerce (unsafeCoerce) -import Node.FS.Aff as FSA -import Fetch (fetch, Method(GET), lookup) -import Fetch.Argonaut.Json (fromJson) -import Data.Maybe (Maybe(..), fromMaybe, isJust) -import JSURI (encodeURIComponent) -import Foreign.Object as Object -import Data.Argonaut (decodeJson, encodeJson, parseJson) -import Data.Argonaut.Core (Json, toObject, toArray, toString, stringify) -import Data.Array ((!!), length, null, mapMaybe, find) -import Data.Tuple (Tuple(..)) -import Data.Foldable (for_, foldM, traverse_) -import Data.Traversable (traverse) -import Db (Connection, FilterField(..), connect, initDb, upsertScrobble, getScrobbles, checkExists, getOldestTs, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb, withTransaction) -import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) -import Control.Monad.Rec.Class (forever) -import Data.Time.Duration (Milliseconds(..)) -import Data.Int (fromString, toNumber) -import Data.String (Pattern(..), stripPrefix) import Node.Process (lookupEnv) -import Data.String.Common (split) as String -import Data.String.Regex (replace, parseFlags) -import Data.String.Regex.Unsafe (unsafeRegex) -import S3 (existsInS3, uploadToS3, getS3Url) -import Effect.Aff.Retry (RetryStatus(..), exponentialBackoff, limitRetries, recovering) +import Node.Stream (end, write, writeString) +import Data.Argonaut (encodeJson, stringify) +import Data.Argonaut.Core (toObject, toArray, toString, Json) +import Foreign.Object as Object +import Sync (lbSync, lbSyncLoop, lfSync, lfSyncLoop) +import Types (Listen(..), ListenBrainzResponse(..), Payload(..), TrackMetadata(..)) +import Unsafe.Coerce (unsafeCoerce) import Web.URL (URL) import Web.URL as URL import Web.URL.URLSearchParams as URLSearchParams @@ -73,258 +64,6 @@ type UserContext = , backupFiber :: Maybe (Fiber Unit) } -listenBrainzUrl :: String -> String -listenBrainzUrl username = "https://api.listenbrainz.org/1/user/" <> username <> "/listens" - -fetchListenBrainzUrl :: String -> Aff String -fetchListenBrainzUrl url = withRetry "ListenBrainz fetch" $ makeAff \callback -> do - req <- HTTPS.get url - - req # on_ Client.responseH \res -> do - launchAff_ do - result <- try $ readableToStringUtf8 (IM.toReadable res) - liftEffect $ case result of - Left err -> - callback (Left err) - Right body -> - if IM.statusCode res == 200 then - callback (Right body) - else - callback (Left $ Exception.error $ "ListenBrainz API returned status " <> show (IM.statusCode res)) - - let errorH = EventHandle "error" mkEffectFn1 - on_ errorH (\err -> callback (Left err)) (unsafeCoerce req) - - pure nonCanceler - -withRetry :: forall a. String -> Aff a -> Aff a -withRetry label action = recovering policy [ \_ _ -> pure true ] \(RetryStatus status) -> do - when (status.iterNumber > 0) - $ Log.warn - $ label <> " failed, retry attempt " <> show status.iterNumber - action - where - policy = exponentialBackoff (Milliseconds 1000.0) <> limitRetries 5 - -fetchLastfmPage :: String -> String -> Int -> Maybe Int -> Aff { tracks :: Array Json, totalPages :: Int } -fetchLastfmPage apiKey lfmUser page mTo = withRetry "Last.fm fetch" do - let - toParam = case mTo of - Just ts -> "&to=" <> show ts - Nothing -> "" - -- Build the base URL without the key so it is safe to log or include in errors. - -- Last.fm does not support header-based auth, so the key must be a query param. - baseUrl = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" - <> (fromMaybe lfmUser $ encodeURIComponent lfmUser) - <> "&format=json&limit=200&page=" - <> show page - <> toParam - url = baseUrl <> "&api_key=" <> apiKey - fr <- fetch url { method: GET } - if fr.status == 200 then do - json <- fromJson fr.json - let - parsed = do - obj <- toObject json - rt <- Object.lookup "recenttracks" obj >>= toObject - tracks <- Object.lookup "track" rt >>= toArray - attr <- Object.lookup "@attr" rt >>= toObject - totalPagesStr <- Object.lookup "totalPages" attr >>= toString - totalPages <- fromString totalPagesStr - pure { tracks, totalPages } - case parsed of - Just result -> pure result - Nothing -> liftEffect $ Exception.error "Last.fm: Failed to parse JSON response" # Exception.throwException - else do - liftEffect $ Exception.error ("Last.fm API returned status " <> show fr.status) # Exception.throwException - -lastfmTrackToListen :: Json -> Maybe Listen -lastfmTrackToListen json = do - obj <- toObject json - trackName <- Object.lookup "name" obj >>= toString - artistObj <- Object.lookup "artist" obj >>= toObject - artistName <- Object.lookup "#text" artistObj >>= toString - albumObj <- Object.lookup "album" obj >>= toObject - let releaseName = Object.lookup "#text" albumObj >>= toString - let - releaseMbid = do - s <- Object.lookup "mbid" albumObj >>= toString - if s == "" then Nothing else Just s - -- nowplaying tracks have no date field — naturally filtered out here - dateObj <- Object.lookup "date" obj >>= toObject - utsStr <- Object.lookup "uts" dateObj >>= toString - ts <- fromString utsStr - pure $ Listen - { listenedAt: Just ts - , trackMetadata: TrackMetadata - { trackName: Just trackName - , artistName: Just artistName - , releaseName: releaseName - , genre: Nothing - , label: Nothing - , mbidMapping: Just $ MbidMapping - { releaseMbid: releaseMbid - , caaReleaseMbid: releaseMbid - } - } - } - -recordSyncSuccess :: String -> String -> Int -> Aff Unit -recordSyncSuccess slug source n = do - liftEffect $ Metrics.incSyncRuns slug source "success" - when (n > 0) $ liftEffect $ Metrics.incSyncScrobbles slug source n - liftEffect $ Metrics.setSyncLastSuccess slug source - --- One complete paginated pass; used for both initial and recurring syncs. -lbSyncOnce :: Connection -> String -> String -> AVar Unit -> Boolean -> Aff Unit -lbSyncOnce conn username slug writeLock initialSyncEnabled = void performFullSync - where - performFullSync = do - when initialSyncEnabled $ Log.info $ "Starting ListenBrainz sync for " <> username - result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100") - case result of - Right body -> do - case parseJson body >>= decodeJson of - Left err -> do - Log.error $ "Sync parse error: " <> show err - liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" - Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - Tuple added (Tuple minTs hitCount) <- withTransaction conn writeLock (processListens listens) - when (added > 0 || initialSyncEnabled) - $ Log.info - $ "ListenBrainz batch: added " <> show added <> ", " <> show hitCount <> " already present." - let allExist = hitCount == length listens && not (null listens) - if allExist || null listens then do - when (added > 0) $ Log.info $ "ListenBrainz sync complete. Added " <> show added <> " new scrobbles." - recordSuccess added - else if initialSyncEnabled then do - total <- paginateUntilDone 2 minTs added - Log.info $ "ListenBrainz sync complete. Added " <> show total <> " new scrobbles." - recordSuccess total - else do - when (added > 0) $ Log.info $ "ListenBrainz sync complete. Added " <> show added <> " new scrobbles." - recordSuccess added - Left err -> do - Log.error $ "Sync fetch error: " <> Exception.message err - liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" - - paginateUntilDone batchNum minTs acc = case minTs of - Nothing -> - pure acc - Just ts -> do - Log.info $ "Fetching ListenBrainz batch " <> show batchNum <> " (before " <> show ts <> ")..." - result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100&max_ts=" <> show ts) - case result of - Right body -> do - case parseJson body >>= decodeJson of - Left err -> do - Log.error $ "Sync parse error: " <> show err - pure acc - Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - Tuple added (Tuple newMinTs hitCount) <- withTransaction conn writeLock (processListens listens) - Log.info $ "ListenBrainz batch " <> show batchNum <> ": added " <> show added <> ", " <> show hitCount <> " already present." - let allExist = hitCount == length listens && not (null listens) - if allExist || null listens then do - pure (acc + added) - else do - paginateUntilDone (batchNum + 1) newMinTs (acc + added) - Left err -> do - Log.error $ "Sync fetch error: " <> Exception.message err - pure acc - - recordSuccess = recordSyncSuccess slug "listenbrainz" - - processListens listens = do - s <- foldM step { added: 0, minTs: Nothing, hitCount: 0 } listens - pure $ Tuple s.added (Tuple s.minTs s.hitCount) - where - step s l@(Listen { listenedAt: Just ts }) = do - exists <- checkExists conn ts - if exists then pure s { minTs = Just ts, hitCount = s.hitCount + 1 } - else do - upsertScrobble conn l - pure s { added = s.added + 1, minTs = Just ts } - step s _ = do - Log.warn "Skipping scrobble without timestamp" - pure s - -lbSyncLoop :: Connection -> String -> String -> AVar Unit -> Boolean -> Aff Unit -lbSyncLoop conn username slug writeLock initialSyncEnabled = forever do - delay (Milliseconds 60000.0) - lbSyncOnce conn username slug writeLock initialSyncEnabled - -lfSyncOnce :: Connection -> String -> String -> String -> AVar Unit -> Boolean -> Aff Unit -lfSyncOnce conn apiKey lfmUser slug writeLock initialSyncEnabled = do - void $ performLastfmSync - when initialSyncEnabled $ void $ performLastfmBackfill - where - performLastfmSync = do - when initialSyncEnabled $ Log.info $ "Starting Last.fm sync for " <> lfmUser - { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 Nothing - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) - when (added > 0 || initialSyncEnabled) - $ Log.info - $ "Last.fm page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." - let - validTracks = mapMaybe lastfmTrackToListen tracks - allExist = hitCount == length validTracks && not (null validTracks) - if allExist || totalPages <= 1 then do - when (added > 0) $ Log.info $ "Last.fm sync complete. Added " <> show added <> " new scrobbles." - recordSuccess added - else if initialSyncEnabled then do - total <- paginateLastfmUntilDone 2 totalPages Nothing added - Log.info $ "Last.fm sync complete. Added " <> show total <> " new scrobbles." - recordSuccess total - else do - when (added > 0) $ Log.info $ "Last.fm sync complete. Added " <> show added <> " new scrobbles." - recordSuccess added - - paginateLastfmUntilDone page totalPages mTo acc - | page > totalPages = pure acc - | otherwise = do - when initialSyncEnabled $ Log.info $ "Fetching Last.fm page " <> show page <> "/" <> show totalPages <> "..." - { tracks } <- fetchLastfmPage apiKey lfmUser page mTo - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) - Log.info $ "Last.fm page " <> show page <> "/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." - let - validTracks = mapMaybe lastfmTrackToListen tracks - allExist = hitCount == length validTracks && not (null validTracks) - if allExist || null tracks then pure (acc + added) - else paginateLastfmUntilDone (page + 1) totalPages mTo (acc + added) - - performLastfmBackfill = do - mOldest <- getOldestTs conn - for_ mOldest \oldestTs -> do - when initialSyncEnabled $ Log.info $ "Checking for Last.fm history before " <> show oldestTs <> "..." - { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 (Just (oldestTs - 1)) - if null tracks then - when initialSyncEnabled $ Log.info "No older Last.fm history found." - else do - Log.info $ "Backfilling " <> show totalPages <> " pages of Last.fm history before " <> show oldestTs - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) - Log.info $ "Last.fm backfill page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." - total <- paginateLastfmUntilDone 2 totalPages (Just (oldestTs - 1)) added - Log.info $ "Last.fm backfill complete. Added " <> show total <> " older scrobbles." - - recordSuccess = recordSyncSuccess slug "lastfm" - - processLastfmTracks tracks = do - s <- foldM step { added: 0, hitCount: 0 } (mapMaybe lastfmTrackToListen tracks) - pure $ Tuple s.added s.hitCount - where - step s l@(Listen { listenedAt: Just ts }) = do - exists <- checkExists conn ts - if exists then pure s { hitCount = s.hitCount + 1 } - else do - upsertScrobble conn l - pure s { added = s.added + 1 } - step s _ = pure s - -lfSyncLoop :: Connection -> String -> String -> String -> AVar Unit -> Boolean -> Aff Unit -lfSyncLoop conn apiKey lfmUser slug writeLock initialSyncEnabled = forever do - delay (Milliseconds 60000.0) - lfSyncOnce conn apiKey lfmUser slug writeLock initialSyncEnabled - normalizePath :: String -> String normalizePath path = case stripPrefix (Pattern "/u/") path of Just _ -> "/u/:slug" @@ -371,9 +110,9 @@ routeRequest metricsEnabled contexts url path allUsers res = liftEffect $ case p "/stats" -> withUser url \ctx -> serveStats ctx.conn url res "/cover" -> - withUser url \ctx -> serveCover ctx.config ctx.slug url res + withUser url \ctx -> launchAff_ $ serveCover serveNotFound ctx.config ctx.slug url res "/similar" -> - withUser url \ctx -> serveSimilar ctx.slug ctx.config url res + withUser url \ctx -> serveSimilar serveBadRequest serveError ctx.slug ctx.config url res "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn res _ -> @@ -442,165 +181,6 @@ parseFilterField "genre" = Just FilterGenre parseFilterField "track" = Just FilterTrack parseFilterField _ = Nothing -sanitizeKey :: String -> String -sanitizeKey = replace re1 "_" >>> replace re2 "_" - where - re1 = unsafeRegex "[^a-z0-9.-]" (parseFlags "gi") - re2 = unsafeRegex "_{2,}" (parseFlags "g") - --- Fetch the Last.fm image URL for an artist/release (no caching, no serving). -fetchLastfmCoverUrl :: UserConfig -> String -> String -> Aff (Maybe String) -fetchLastfmCoverUrl cfg artist release = case cfg.lastfmApiKey of - Nothing -> - pure Nothing - Just k -> do - let - searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&artist=" - <> (fromMaybe "" $ encodeURIComponent artist) - <> "&album=" - <> (fromMaybe "" $ encodeURIComponent release) - <> "&format=json&api_key=" - <> k - Log.info $ "Searching Last.fm for: " <> artist <> " - " <> release - result <- try $ fetch searchUrl { method: GET } - case result of - Right fr | fr.status == 200 -> do - json <- fromJson fr.json - pure $ do - obj <- toObject json - album <- Object.lookup "album" obj >>= toObject - images <- Object.lookup "image" album >>= toArray - imgObj <- images !! 2 >>= toObject - u <- Object.lookup "#text" imgObj >>= toString - if u == "" then Nothing else Just u - _ -> - pure Nothing - --- Fetch the Discogs image URL for an artist/release (no caching, no serving). -fetchDiscogsCoverUrl :: UserConfig -> String -> String -> Aff (Maybe String) -fetchDiscogsCoverUrl cfg artist release = case cfg.discogsToken of - Nothing -> - pure Nothing - Just t -> do - let - queryStr = artist <> " " <> release - searchUrl = "https://api.discogs.com/database/search?q=" - <> (fromMaybe "" $ encodeURIComponent queryStr) - <> "&type=release&per_page=1" - Log.info $ "Searching Discogs for: " <> queryStr - result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Discogs token=" <> t } } - case result of - Right fr | fr.status == 200 -> do - json <- fromJson fr.json - pure $ do - obj <- toObject json - results <- Object.lookup "results" obj >>= toArray - firstResult <- results !! 0 >>= toObject - Object.lookup "cover_image" firstResult >>= toString - _ -> - pure Nothing - -type CoverSource = - { name :: String - , s3Key :: String - , findUrl :: Aff (Maybe String) - } - -coverSources :: String -> String -> String -> UserConfig -> Array CoverSource -coverSources mbid artist release cfg = - let - safeArtist = sanitizeKey artist - safeRelease = sanitizeKey release - in - [ { name: "caa" - , s3Key: "covers/caa/" <> sanitizeKey mbid <> ".jpg" - , findUrl: - if mbid == "" then pure Nothing - else pure $ Just $ "https://coverartarchive.org/release/" <> mbid <> "/front-250" - } - , { name: "lastfm" - , s3Key: "covers/lastfm/" <> safeArtist <> "-" <> safeRelease <> ".jpg" - , findUrl: - if artist == "" || release == "" then pure Nothing - else fetchLastfmCoverUrl cfg artist release - } - , { name: "discogs" - , s3Key: "covers/discogs/" <> safeArtist <> "-" <> safeRelease <> ".jpg" - , findUrl: - if artist == "" || release == "" then pure Nothing - else fetchDiscogsCoverUrl cfg artist release - } - ] - -serveCover :: UserConfig -> String -> URL -> Response -> Effect Unit -serveCover cfg slug url res = launchAff_ do - let - mbid = fromMaybe "" (getQueryParam "mbid" url) - artist = fromMaybe "" (getQueryParam "artist" url) - release = fromMaybe "" (getQueryParam "release" url) - s3cfg = s3ConfigFromUser cfg - - served <- foldM (trySource s3cfg) false (coverSources mbid artist release cfg) - unless served $ liftEffect $ serveNotFound res - - where - trySource _ true _ = pure true - trySource s3cfg false { name, s3Key, findUrl } = do - cached <- checkS3 s3cfg s3Key - if cached then do - Log.info $ "Serving " <> name <> " cover from S3: " <> s3Key - liftEffect $ Metrics.incCoverRequest slug name "s3_hit" - serveS3 s3cfg s3Key res - pure true - else do - mUrl <- findUrl - case mUrl of - Nothing -> - pure false - Just urlStr -> do - success <- tryProxyAndCache s3cfg urlStr s3Key res - liftEffect $ - if success then Metrics.incCoverRequest slug name "fetch" - else Metrics.incCoverRequest slug name "miss" - pure success - - checkS3 s3cfg s3Key - | not cfg.coverCacheEnabled = pure false - | otherwise = do - result <- try $ existsInS3 s3cfg s3Key - pure $ case result of - Right b -> b - Left _ -> false - - serveS3 s3cfg s3Key response = liftEffect $ do - setStatusCode 302 response - setHeader "Location" (getS3Url s3cfg s3Key) (toOutgoingMessage response) - end (toWriteable (toOutgoingMessage response)) - - tryProxyAndCache s3cfg urlStr s3Key response = do - fetchResult <- try $ fetch urlStr { method: GET } - case fetchResult of - Right fr | fr.status == 200 -> do - Log.info $ "Proxying and caching image: " <> urlStr - let contentType = fromMaybe "image/jpeg" $ lookup "content-type" fr.headers - buf <- fr.arrayBuffer - liftEffect $ do - setStatusCode fr.status response - setHeader "Content-Type" contentType (toOutgoingMessage response) - setHeader "Cache-Control" "public, max-age=86400" (toOutgoingMessage response) - let writer = toWriteable (toOutgoingMessage response) - nativeBuf <- fromArrayBuffer buf - void $ write writer nativeBuf - end writer - when cfg.coverCacheEnabled $ void $ forkAff $ do - uploadResult <- try $ uploadToS3 s3cfg s3Key (unsafeCoerce buf) contentType - case uploadResult of - Right _ -> Log.info $ "Cached to S3: " <> s3Key - Left err -> Log.error $ "S3 upload failed: " <> Exception.message err - pure true - _ -> - pure false - serveProxy :: Connection -> URL -> Response -> Effect Unit serveProxy db url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) @@ -624,6 +204,23 @@ serveProxy db url res = do void $ writeString w UTF8 responseBody end w +serveStats :: Connection -> URL -> Response -> Effect Unit +serveStats db url res = do + setHeader "Content-Type" "application/json" (toOutgoingMessage res) + launchAff_ do + let period = getQueryParam "period" url + let section = getQueryParam "section" url + let safeDate = map (replace (unsafeRegex "[^0-9\\-]" (parseFlags "g")) "") + let mFrom = safeDate (getQueryParam "from" url) + let mTo = safeDate (getQueryParam "to" url) + stats <- getStats db period mFrom mTo section + let responseBody = stringify $ encodeJson stats + liftEffect $ do + setStatusCode 200 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 responseBody + end w + serveAsset :: String -> String -> Response -> Effect Unit serveAsset contentType path res = do setHeader "Content-Type" contentType (toOutgoingMessage res) @@ -687,289 +284,6 @@ serveError res statusCode statusName message = do void $ writeString w UTF8 (statusName <> ": " <> message) end w -fetchCosineSimilar :: String -> UserConfig -> String -> Aff String -fetchCosineSimilar slug cfg query = do - let apiKey = fromMaybe "" cfg.cosineApiKey - if apiKey == "" then do - Log.warn "cosineApiKey not configured for similar tracks" - liftEffect $ Metrics.incCosineRequest slug "not_configured" - pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" - else do - let headers = { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Bearer " <> apiKey } - let searchUrl = "https://cosine.club/api/v1/search?q=" <> (fromMaybe "" $ encodeURIComponent query) <> "&limit=1" - Log.info $ "Cosine Club: searching for: " <> query - searchResult <- try $ fetch searchUrl { method: GET, headers: headers } - case searchResult of - Left err -> do - liftEffect $ Metrics.incCosineRequest slug "error" - throwError err - Right searchRes -> - if searchRes.status == 429 then do - Log.warn "Cosine Club: rate limited on search" - liftEffect $ Metrics.incCosineRequest slug "rate_limited" - throwError (Exception.error "Rate limit exceeded") - else if searchRes.status /= 200 then do - Log.warn $ "Cosine Club: search returned " <> show searchRes.status - liftEffect $ Metrics.incCosineRequest slug "error" - throwError (Exception.error "Search API error") - else do - searchBody <- searchRes.text - let - mTrackId = do - json <- hush $ parseJson searchBody - obj <- toObject json - arr <- Object.lookup "data" obj >>= toArray - first <- arr !! 0 - firstObj <- toObject first - Object.lookup "id" firstObj >>= toString - case mTrackId of - Nothing -> do - Log.info $ "Cosine Club: track not indexed: " <> query - liftEffect $ Metrics.incCosineRequest slug "not_indexed" - pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" - Just trackId -> do - let similarUrl = "https://cosine.club/api/v1/tracks/" <> trackId <> "/similar?limit=10" - Log.info $ "Cosine Club: fetching similar for ID " <> trackId - similarResult <- try $ fetch similarUrl { method: GET, headers: headers } - case similarResult of - Left err -> do - liftEffect $ Metrics.incCosineRequest slug "error" - throwError err - Right similarRes -> - if similarRes.status == 429 then do - Log.warn "Cosine Club: rate limited on similar" - liftEffect $ Metrics.incCosineRequest slug "rate_limited" - throwError (Exception.error "Rate limit exceeded") - else if similarRes.status /= 200 then do - Log.warn $ "Cosine Club: similar API returned " <> show similarRes.status - liftEffect $ Metrics.incCosineRequest slug "error" - throwError (Exception.error "Similar API error") - else do - liftEffect $ Metrics.incCosineRequest slug "success" - similarRes.text - -serveStats :: Connection -> URL -> Response -> Effect Unit -serveStats db url res = do - setHeader "Content-Type" "application/json" (toOutgoingMessage res) - launchAff_ do - let period = getQueryParam "period" url - let section = getQueryParam "section" url - let safeDate = map (replace (unsafeRegex "[^0-9\\-]" (parseFlags "g")) "") - let mFrom = safeDate (getQueryParam "from" url) - let mTo = safeDate (getQueryParam "to" url) - stats <- getStats db period mFrom mTo section - let responseBody = stringify $ encodeJson stats - liftEffect $ do - setStatusCode 200 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 responseBody - end w - -serveSimilar :: String -> UserConfig -> URL -> Response -> Effect Unit -serveSimilar slug cfg url res = do - setHeader "Content-Type" "application/json" (toOutgoingMessage res) - launchAff_ do - let artist = fromMaybe "" (getQueryParam "artist" url) - let track = fromMaybe "" (getQueryParam "track" url) - if artist == "" || track == "" then - liftEffect $ serveBadRequest res "Artist and track parameters are required" - else do - let query = artist <> " - " <> track - result <- try $ fetchCosineSimilar slug cfg query - - case result of - Left err -> do - Log.error $ "Cosine Club API error: " <> Exception.message err - liftEffect $ serveError res 502 "Bad Gateway" "Failed to fetch similar tracks" - Right responseBody -> do - liftEffect $ do - setStatusCode 200 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 responseBody - end w - -type MbData = { genre :: Maybe String, label :: Maybe String, year :: Maybe Int } - -fetchMusicBrainzRelease :: String -> Aff (Maybe MbData) -fetchMusicBrainzRelease mbid = do - let url = "https://musicbrainz.org/ws/2/release/" <> mbid <> "?inc=genres+labels+release-groups&fmt=json" - result <- try $ fetch url { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus" } } - case result of - Left err -> do - Log.error $ "MusicBrainz fetch error for " <> mbid <> ": " <> Exception.message err - pure Nothing - Right fr | fr.status == 200 -> do - jsonResult <- try $ fromJson fr.json - case jsonResult of - Left err -> do - Log.error $ "MusicBrainz JSON parse error for " <> mbid <> ": " <> Exception.message err - pure $ Just { genre: Nothing, label: Nothing, year: Nothing } - Right json -> do - let - genre = do - obj <- toObject json - genres <- Object.lookup "genres" obj >>= toArray - firstGenre <- genres !! 0 >>= toObject - Object.lookup "name" firstGenre >>= toString - label = do - obj <- toObject json - labelInfo <- Object.lookup "label-info" obj >>= toArray - firstLabel <- labelInfo !! 0 >>= toObject - labelObj <- Object.lookup "label" firstLabel >>= toObject - Object.lookup "name" labelObj >>= toString - year = do - obj <- toObject json - rg <- Object.lookup "release-group" obj >>= toObject - dateStr <- Object.lookup "first-release-date" rg >>= toString - String.split (Pattern "-") dateStr !! 0 >>= fromString - Log.info $ "Enriched " <> mbid <> ": genre=" <> show genre <> " label=" <> show label <> " year=" <> show year - - when (genre == Nothing && label == Nothing && year == Nothing) - $ Log.warn - $ "All fields empty for " <> mbid <> " - possible parsing issue or missing data" - - pure $ Just { genre, label, year } - Right fr | fr.status == 404 -> do - Log.info $ "MusicBrainz 404 for " <> mbid - pure $ Just { genre: Nothing, label: Nothing, year: Nothing } - Right fr -> do - Log.warn $ "MusicBrainz " <> show fr.status <> " for " <> mbid <> ", will retry" - pure Nothing - --- Explicit apiKey parameter — no env read. -fetchLastfmGenre :: Maybe String -> String -> String -> Aff (Maybe String) -fetchLastfmGenre Nothing _ _ = do - Log.warn "lastfmApiKey not configured for genre fallback" - pure Nothing -fetchLastfmGenre (Just k) artist release = do - let searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&album=" <> (fromMaybe "" $ encodeURIComponent release) <> "&format=json" <> "&api_key=" <> k - Log.info $ "Fetching Last.fm genre for: " <> artist <> " - " <> release - result <- try $ fetch searchUrl { method: GET } - case result of - Right fetchRes | fetchRes.status == 200 -> do - jsonResult <- try $ fromJson fetchRes.json - case jsonResult of - Left err -> do - Log.error $ "Last.fm genre JSON error: " <> Exception.message err - pure Nothing - Right json -> do - let - genre = do - obj <- toObject json - album <- Object.lookup "album" obj >>= toObject - tags <- Object.lookup "tags" album >>= toObject - tagArray <- Object.lookup "tag" tags >>= toArray - firstTag <- tagArray !! 0 >>= toObject - Object.lookup "name" firstTag >>= toString - pure genre - _ -> do - Log.warn "Last.fm genre API request failed" - pure Nothing - --- Explicit token parameter — no env read. -fetchDiscogsGenre :: Maybe String -> String -> String -> Aff (Maybe String) -fetchDiscogsGenre Nothing _ _ = do - Log.warn "discogsToken not configured for genre fallback" - pure Nothing -fetchDiscogsGenre (Just t) artist release = do - let queryStr = artist <> " " <> release - let searchUrl = "https://api.discogs.com/database/search?q=" <> (fromMaybe "" $ encodeURIComponent queryStr) <> "&type=release&per_page=1" - Log.info $ "Fetching Discogs genre for: " <> queryStr - result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Discogs token=" <> t } } - case result of - Right fetchRes | fetchRes.status == 200 -> do - jsonResult <- try $ fromJson fetchRes.json - case jsonResult of - Left err -> do - Log.error $ "Discogs genre JSON error: " <> Exception.message err - pure Nothing - Right json -> do - let - genre = do - obj <- toObject json - results <- Object.lookup "results" obj >>= toArray - firstResult <- results !! 0 >>= toObject - genres <- Object.lookup "genres" firstResult >>= toArray - genres !! 0 >>= toString - pure genre - _ -> do - Log.warn "Discogs genre API request failed" - pure Nothing - -type GenreSource = - { name :: String - , enabled :: Boolean - , fetch :: Aff (Maybe String) - } - --- Try each source in order, stopping at the first that returns Just. --- Records found/not_found metrics only for sources that are enabled and queried. -fetchFallbackGenre :: String -> Array GenreSource -> Aff (Maybe String) -fetchFallbackGenre slug = foldM trySource Nothing - where - trySource (Just g) _ = pure (Just g) - trySource Nothing { name, enabled, fetch } - | not enabled = pure Nothing - | otherwise = do - result <- fetch - liftEffect $ case result of - Just _ -> Metrics.incEnrichmentFetch slug name "found" - Nothing -> Metrics.incEnrichmentFetch slug name "not_found" - pure result - -enrichMetadata :: Connection -> UserConfig -> String -> Aff Unit -enrichMetadata conn cfg slug = forever do - unenrichedMbids <- getUnenrichedMbids conn 10 - emptyGenreMbids <- getEmptyGenreMbids conn 10 - let allMbids = unenrichedMbids <> emptyGenreMbids - - liftEffect $ Metrics.setEnrichmentQueueSize slug "unenriched" (length unenrichedMbids) - liftEffect $ Metrics.setEnrichmentQueueSize slug "empty_genre" (length emptyGenreMbids) - - if null allMbids then - delay (Milliseconds 60000.0) - else do - Log.info $ "Processing " <> show (length unenrichedMbids) <> " unenriched + " <> show (length emptyGenreMbids) <> " empty genre releases" - artistReleaseMap <- getArtistReleasesByMbids conn allMbids - for_ allMbids \mbid -> do - delay (Milliseconds 1100.0) - result <- try $ fetchMusicBrainzRelease mbid - case result of - Left err -> do - Log.error $ "Enrichment error: " <> Exception.message err - liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "error" - Right Nothing -> do - liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "retry" - upsertReleaseMetadata conn mbid Nothing Nothing Nothing - touchGenreCheckedAt conn mbid - Right (Just mbdata) -> do - liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "success" - case mbdata.genre of - Just _ -> - upsertReleaseMetadata conn mbid mbdata.genre mbdata.label mbdata.year - Nothing -> do - let artistRelease = Object.lookup mbid artistReleaseMap - case artistRelease of - Just { artist, release } -> do - let - sources = - [ { name: "lastfm", enabled: isJust cfg.lastfmApiKey, fetch: fetchLastfmGenre cfg.lastfmApiKey artist release } - , { name: "discogs", enabled: isJust cfg.discogsToken, fetch: fetchDiscogsGenre cfg.discogsToken artist release } - ] - finalGenre <- fetchFallbackGenre slug sources - upsertReleaseMetadata conn mbid finalGenre mbdata.label mbdata.year - case finalGenre of - Just genre -> - Log.info $ "Added fallback genre for " <> mbid <> ": " <> genre - Nothing -> do - Log.info $ "No genre found in any source for " <> mbid - touchGenreCheckedAt conn mbid - Nothing -> do - Log.warn $ "No artist/release info found for MBID " <> mbid <> ", cannot use fallback sources" - upsertReleaseMetadata conn mbid mbdata.genre mbdata.label mbdata.year - touchGenreCheckedAt conn mbid - --- Initialise one user: connect DB, start sync loops, return a UserContext. startUser :: UserEntry -> Aff UserContext startUser { slug, name, config } = do Log.info $ "Starting user: " <> if slug == "" then "(root)" else slug @@ -986,10 +300,10 @@ startUser { slug, name, config } = do -- start immediately. A separate fiber waits for them to finish before -- starting the recurring loops (preserving the original ordering guarantee). lbFiber <- case config.listenbrainzUser of - Just username -> Just <$> forkAff (lbSyncOnce conn username slug writeLock config.initialSync) + Just username -> Just <$> forkAff (lbSync conn username slug writeLock) Nothing -> pure Nothing lfFiber <- case config.lastfmUser, config.lastfmApiKey of - Just lfmUser, Just apiKey -> Just <$> forkAff (lfSyncOnce conn apiKey lfmUser slug writeLock config.initialSync) + Just lfmUser, Just apiKey -> Just <$> forkAff (lfSync conn apiKey lfmUser slug writeLock) _, _ -> pure Nothing -- Wait for initial syncs then start recurring loops — all in background. @@ -997,9 +311,9 @@ startUser { slug, name, config } = do for_ lbFiber joinFiber for_ lfFiber joinFiber for_ config.listenbrainzUser \username -> - void $ forkAff $ lbSyncLoop conn username slug writeLock config.initialSync + void $ forkAff $ lbSyncLoop conn username slug writeLock case config.lastfmUser, config.lastfmApiKey of - Just lfmUser, Just apiKey -> void $ forkAff $ lfSyncLoop conn apiKey lfmUser slug writeLock config.initialSync + Just lfmUser, Just apiKey -> void $ forkAff $ lfSyncLoop conn apiKey lfmUser slug writeLock _, _ -> pure unit -- Background tasks that don't depend on initial sync completion. @@ -1013,23 +327,13 @@ startUser { slug, name, config } = do cleanupUser :: UserContext -> Aff Unit cleanupUser ctx = do - Log.info $ "Cleaning up user: " <> if ctx.slug == "" then "(root)" else ctx.slug - -- Kill background fibers - case ctx.enrichMetadataFiber of - Just fiber -> do - Log.info "Killing enrich metadata fiber" - void $ try $ killFiber (Exception.error "Server shutting down") fiber - Nothing -> do - pure unit - case ctx.backupFiber of - Just fiber -> do - Log.info "Killing backup fiber" - void $ try $ killFiber (Exception.error "Server shutting down") fiber - Nothing -> do - pure unit - -- Close database connection - Log.info "Closing database connection" - void $ try $ ping ctx.conn + let label = if ctx.slug == "" then "(root)" else ctx.slug + Log.info $ "Shutting down user: " <> label + for_ ctx.enrichMetadataFiber \fiber -> + void $ try $ killFiber (Exception.error "Server shutting down") fiber + for_ ctx.backupFiber \fiber -> + void $ try $ killFiber (Exception.error "Server shutting down") fiber + Log.info $ "Closed user: " <> label foreign import dotenvConfig :: Effect Unit @@ -1044,7 +348,7 @@ main = do Log.error $ "Failed to load " <> configFile <> ": " <> Exception.message err liftEffect $ Exception.throwException err Right (appConfig :: AppConfig) -> do - Log.info $ "Loaded " <> show (length appConfig.users) <> " user(s) from users.dhall" + Log.info $ "Loaded " <> show (length appConfig.users) <> " user(s) from " <> configFile contexts <- traverse startUser appConfig.users liftEffect $ do server <- createServer @@ -1056,8 +360,3 @@ main = do listenTcp netServer { host: "127.0.0.1", port: appConfig.port, backlog: 128 } -cleanupAll :: Array UserContext -> Aff Unit -cleanupAll contexts = do - Log.info "Starting graceful shutdown of all users" - traverse_ cleanupUser contexts - Log.info "Graceful shutdown complete" diff --git a/src/Metadata.purs b/src/Metadata.purs @@ -0,0 +1,208 @@ +module Metadata + ( MbData + , GenreSource + , fetchMusicBrainzRelease + , fetchLastfmGenre + , fetchDiscogsGenre + , fetchFallbackGenre + , enrichMetadata + ) where + +import Prelude + +import Config (UserConfig) +import Control.Monad.Rec.Class (forever) +import Data.Array ((!!), length, null) +import Data.Either (Either(..)) +import Data.Foldable (foldM, for_) +import Data.Int (fromString) +import Data.Maybe (Maybe(..), fromMaybe, isJust) +import Data.String (Pattern(..)) +import Data.String.Common as String +import Data.Time.Duration (Milliseconds(..)) +import Data.Argonaut.Core (toArray, toObject, toString) +import Db (Connection, getArtistReleasesByMbids, getEmptyGenreMbids, getUnenrichedMbids, touchGenreCheckedAt, upsertReleaseMetadata) +import Effect.Aff (Aff, delay, try) +import Effect.Class (liftEffect) +import Effect.Exception as Exception +import Fetch (fetch, Method(GET)) +import Fetch.Argonaut.Json (fromJson) +import Foreign.Object as Object +import JSURI (encodeURIComponent) +import Log as Log +import Metrics as Metrics + +type MbData = { genre :: Maybe String, label :: Maybe String, year :: Maybe Int } + +type GenreSource = + { name :: String + , enabled :: Boolean + , fetch :: Aff (Maybe String) + } + +fetchMusicBrainzRelease :: String -> Aff (Maybe MbData) +fetchMusicBrainzRelease mbid = do + let url = "https://musicbrainz.org/ws/2/release/" <> mbid <> "?inc=genres+labels+release-groups&fmt=json" + result <- try $ fetch url { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus" } } + case result of + Left err -> do + Log.error $ "MusicBrainz fetch error for " <> mbid <> ": " <> Exception.message err + pure Nothing + Right fr | fr.status == 200 -> do + jsonResult <- try $ fromJson fr.json + case jsonResult of + Left err -> do + Log.error $ "MusicBrainz JSON parse error for " <> mbid <> ": " <> Exception.message err + pure $ Just { genre: Nothing, label: Nothing, year: Nothing } + Right json -> do + let + genre = do + obj <- toObject json + genres <- Object.lookup "genres" obj >>= toArray + firstGenre <- genres !! 0 >>= toObject + Object.lookup "name" firstGenre >>= toString + label = do + obj <- toObject json + labelInfo <- Object.lookup "label-info" obj >>= toArray + firstLabel <- labelInfo !! 0 >>= toObject + labelObj <- Object.lookup "label" firstLabel >>= toObject + Object.lookup "name" labelObj >>= toString + year = do + obj <- toObject json + rg <- Object.lookup "release-group" obj >>= toObject + dateStr <- Object.lookup "first-release-date" rg >>= toString + String.split (Pattern "-") dateStr !! 0 >>= fromString + Log.info $ "Enriched " <> mbid <> ": genre=" <> show genre <> " label=" <> show label <> " year=" <> show year + when (genre == Nothing && label == Nothing && year == Nothing) + $ Log.warn + $ "All fields empty for " <> mbid <> " - possible parsing issue or missing data" + pure $ Just { genre, label, year } + Right fr | fr.status == 404 -> do + Log.info $ "MusicBrainz 404 for " <> mbid + pure $ Just { genre: Nothing, label: Nothing, year: Nothing } + Right fr -> do + Log.warn $ "MusicBrainz " <> show fr.status <> " for " <> mbid <> ", will retry" + pure Nothing + +fetchLastfmGenre :: Maybe String -> String -> String -> Aff (Maybe String) +fetchLastfmGenre Nothing _ _ = do + Log.warn "lastfmApiKey not configured for genre fallback" + pure Nothing +fetchLastfmGenre (Just k) artist release = do + let searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&album=" <> (fromMaybe "" $ encodeURIComponent release) <> "&format=json" <> "&api_key=" <> k + Log.info $ "Fetching Last.fm genre for: " <> artist <> " - " <> release + result <- try $ fetch searchUrl { method: GET } + case result of + Right fetchRes | fetchRes.status == 200 -> do + jsonResult <- try $ fromJson fetchRes.json + case jsonResult of + Left err -> do + Log.error $ "Last.fm genre JSON error: " <> Exception.message err + pure Nothing + Right json -> do + let + genre = do + obj <- toObject json + album <- Object.lookup "album" obj >>= toObject + tags <- Object.lookup "tags" album >>= toObject + tagArray <- Object.lookup "tag" tags >>= toArray + firstTag <- tagArray !! 0 >>= toObject + Object.lookup "name" firstTag >>= toString + pure genre + _ -> do + Log.warn "Last.fm genre API request failed" + pure Nothing + +fetchDiscogsGenre :: Maybe String -> String -> String -> Aff (Maybe String) +fetchDiscogsGenre Nothing _ _ = do + Log.warn "discogsToken not configured for genre fallback" + pure Nothing +fetchDiscogsGenre (Just t) artist release = do + let queryStr = artist <> " " <> release + let searchUrl = "https://api.discogs.com/database/search?q=" <> (fromMaybe "" $ encodeURIComponent queryStr) <> "&type=release&per_page=1" + Log.info $ "Fetching Discogs genre for: " <> queryStr + result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Discogs token=" <> t } } + case result of + Right fetchRes | fetchRes.status == 200 -> do + jsonResult <- try $ fromJson fetchRes.json + case jsonResult of + Left err -> do + Log.error $ "Discogs genre JSON error: " <> Exception.message err + pure Nothing + Right json -> do + let + genre = do + obj <- toObject json + results <- Object.lookup "results" obj >>= toArray + firstResult <- results !! 0 >>= toObject + genres <- Object.lookup "genres" firstResult >>= toArray + genres !! 0 >>= toString + pure genre + _ -> do + Log.warn "Discogs genre API request failed" + pure Nothing + +fetchFallbackGenre :: String -> Array GenreSource -> Aff (Maybe String) +fetchFallbackGenre slug = foldM trySource Nothing + where + trySource (Just g) _ = pure (Just g) + trySource Nothing { name, enabled, fetch } + | not enabled = pure Nothing + | otherwise = do + result <- fetch + liftEffect $ case result of + Just _ -> Metrics.incEnrichmentFetch slug name "found" + Nothing -> Metrics.incEnrichmentFetch slug name "not_found" + pure result + +enrichMetadata :: Connection -> UserConfig -> String -> Aff Unit +enrichMetadata conn cfg slug = forever do + unenrichedMbids <- getUnenrichedMbids conn 10 + emptyGenreMbids <- getEmptyGenreMbids conn 10 + let allMbids = unenrichedMbids <> emptyGenreMbids + + liftEffect $ Metrics.setEnrichmentQueueSize slug "unenriched" (length unenrichedMbids) + liftEffect $ Metrics.setEnrichmentQueueSize slug "empty_genre" (length emptyGenreMbids) + + if null allMbids then + delay (Milliseconds 60000.0) + else do + Log.info $ "Processing " <> show (length unenrichedMbids) <> " unenriched + " <> show (length emptyGenreMbids) <> " empty genre releases" + artistReleaseMap <- getArtistReleasesByMbids conn allMbids + for_ allMbids \mbid -> do + delay (Milliseconds 1100.0) + result <- try $ fetchMusicBrainzRelease mbid + case result of + Left err -> do + Log.error $ "Enrichment error: " <> Exception.message err + liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "error" + Right Nothing -> do + liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "retry" + upsertReleaseMetadata conn mbid Nothing Nothing Nothing + touchGenreCheckedAt conn mbid + Right (Just mbdata) -> do + liftEffect $ Metrics.incEnrichmentFetch slug "musicbrainz" "success" + case mbdata.genre of + Just _ -> + upsertReleaseMetadata conn mbid mbdata.genre mbdata.label mbdata.year + Nothing -> do + let artistRelease = Object.lookup mbid artistReleaseMap + case artistRelease of + Just { artist, release } -> do + let + sources = + [ { name: "lastfm", enabled: isJust cfg.lastfmApiKey, fetch: fetchLastfmGenre cfg.lastfmApiKey artist release } + , { name: "discogs", enabled: isJust cfg.discogsToken, fetch: fetchDiscogsGenre cfg.discogsToken artist release } + ] + finalGenre <- fetchFallbackGenre slug sources + upsertReleaseMetadata conn mbid finalGenre mbdata.label mbdata.year + case finalGenre of + Just genre -> + Log.info $ "Added fallback genre for " <> mbid <> ": " <> genre + Nothing -> do + Log.info $ "No genre found in any source for " <> mbid + touchGenreCheckedAt conn mbid + Nothing -> do + Log.warn $ "No artist/release info found for MBID " <> mbid <> ", cannot use fallback sources" + upsertReleaseMetadata conn mbid mbdata.genre mbdata.label mbdata.year + touchGenreCheckedAt conn mbid +\ No newline at end of file diff --git a/src/State.elm b/src/State.elm @@ -0,0 +1,502 @@ +module State exposing (init, subscriptions, update) + +import Api exposing (fetchListens, fetchSectionData, fetchSimilarTracks, fetchStats, httpErrorToString) +import Browser +import Browser.Navigation as Nav +import Dict +import Set +import Task +import Time +import Types exposing (Flags, Model, Msg(..), Period(..), SimilarState(..), Stats, StatsEntry, Tab(..)) +import Url +import Url.Parser exposing ((</>), (<?>)) +import Url.Parser.Query as Query + + +init : Flags -> Url.Url -> Nav.Key -> ( Model, Cmd Msg ) +init flags url navKey = + let + page = + parsePageParam url + + offset = + max 0 ((page - 1) * 25) + in + ( { navKey = navKey + , listens = [] + , stats = Nothing + , error = Nothing + , loading = True + , currentTime = Nothing + , failedCovers = Set.empty + , hoveredCover = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , offset = offset + , limit = 25 + , activeTab = ListensTab + , activeFilter = Nothing + , statsPeriod = AllTime + , customInput = "" + , showCustomInput = False + , customError = Nothing + , userSlug = flags.userSlug + , similarStates = Dict.empty + , allUsers = flags.allUsers + , searchInput = "" + , activeSearch = Nothing + } + , Cmd.batch + [ fetchListens flags.userSlug 25 offset Nothing Nothing + , Task.perform GotTime Time.now + ] + ) + + +parsePageParam : Url.Url -> Int +parsePageParam url = + let + pageParser = + Url.Parser.oneOf + [ Url.Parser.top <?> Query.int "page" + , (Url.Parser.s "u" </> Url.Parser.string <?> Query.int "page") |> Url.Parser.map (\_ p -> p) + ] + in + Url.Parser.parse pageParser url + |> Maybe.andThen identity + |> Maybe.withDefault 1 + + + +-- MSG + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + UrlRequested urlRequest -> + case urlRequest of + Browser.Internal url -> + ( model, Nav.pushUrl model.navKey (Url.toString url) ) + + Browser.External href -> + ( model, Nav.load href ) + + UrlChanged url -> + let + newUserSlug = + if String.startsWith "/u/" url.path then + String.dropLeft 3 url.path + + else + "" + + page = + parsePageParam url + + targetOffset = + max 0 ((page - 1) * model.limit) + + isNaked = + url.query == Nothing + + userChanged = + newUserSlug /= model.userSlug + + needsCleanStart = + userChanged || isNaked + + finalFilter = + if needsCleanStart then + Nothing + + else + model.activeFilter + + finalSearch = + if needsCleanStart then + Nothing + + else + model.activeSearch + + finalOffset = + if needsCleanStart then + 0 + + else + targetOffset + in + if userChanged || model.offset /= finalOffset || model.activeFilter /= finalFilter || model.activeSearch /= finalSearch then + let + newModel = + { model + | userSlug = newUserSlug + , offset = finalOffset + , activeFilter = finalFilter + , activeSearch = finalSearch + , searchInput = + if needsCleanStart then + "" + + else + model.searchInput + , activeTab = ListensTab + , loading = True + } + + clearedModel = + if userChanged then + { newModel + | stats = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , similarStates = Dict.empty + } + + else + newModel + in + ( clearedModel + , fetchListens newUserSlug model.limit finalOffset finalFilter finalSearch + ) + + else + ( { model | activeTab = ListensTab }, Cmd.none ) + + Tick time -> + if not model.loading then + ( { model | currentTime = Just time, loading = True } + , fetchListens model.userSlug model.limit model.offset model.activeFilter model.activeSearch + ) + + else + ( { model | currentTime = Just time }, Cmd.none ) + + GotTime time -> + ( { model | currentTime = Just time }, Cmd.none ) + + GotListens result -> + let + newModel = + case result of + Err err -> + { model | loading = False, error = Just (httpErrorToString err) } + + Ok listens -> + { model | loading = False, listens = listens, error = Nothing } + in + ( newModel, Task.perform GotTime Time.now ) + + GotStats result -> + case result of + Err err -> + ( { model | error = Just (httpErrorToString err) }, Cmd.none ) + + Ok stats -> + ( { model | stats = Just stats }, Cmd.none ) + + GotSectionData section result -> + case result of + Err err -> + ( { model | error = Just (httpErrorToString err) }, Cmd.none ) + + Ok entries -> + ( { model + | expandedSections = Set.insert section model.expandedSections + , loadedSections = Set.insert section model.loadedSections + , stats = Maybe.map (patchStatSection section entries) model.stats + } + , Cmd.none + ) + + ImageError url -> + ( { model | failedCovers = Set.insert url model.failedCovers }, Cmd.none ) + + NextPage -> + let + newOffset = + model.offset + model.limit + + page = + newOffset // model.limit + 1 + + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + ( { model | offset = newOffset, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch + , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page) + ] + ) + + PrevPage -> + let + newOffset = + max 0 (model.offset - model.limit) + + page = + newOffset // model.limit + 1 + + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + ( { model | offset = newOffset, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch + , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page) + ] + ) + + SwitchTab tab -> + ( { model | activeTab = tab } + , case tab of + StatsTab -> + if model.stats == Nothing then + fetchStats model.userSlug model.statsPeriod + + else + Cmd.none + + ListensTab -> + Cmd.none + + AboutTab -> + Cmd.none + ) + + SetStatsPeriod period -> + ( { model + | statsPeriod = period + , stats = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , showCustomInput = False + } + , fetchStats model.userSlug period + ) + + OpenCustomInput -> + let + prefill = + case model.statsPeriod of + CustomRange from to -> + from ++ " " ++ to + + _ -> + model.customInput + in + ( { model | showCustomInput = True, customInput = prefill, customError = Nothing } + , Cmd.none + ) + + UpdateCustomInput str -> + ( { model | customInput = str, customError = Nothing }, Cmd.none ) + + ApplyCustomPeriod -> + let + parts = + model.customInput + |> String.split " " + |> List.filter (not << String.isEmpty) + in + case parts of + [ from, to ] -> + if String.length from /= 10 || String.length to /= 10 then + ( { model | customError = Just "Dates must be in YYYY-MM-DD format" }, Cmd.none ) + + else if from > to then + ( { model | customError = Just "'from' must be before 'to'" }, Cmd.none ) + + else + let + period = + CustomRange from to + in + ( { model + | statsPeriod = period + , stats = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , showCustomInput = False + , customError = Nothing + } + , fetchStats model.userSlug period + ) + + _ -> + ( { model | customError = Just "Enter two dates separated by a space" }, Cmd.none ) + + FilterBy field value -> + let + filter = + Just { field = field, value = value } + + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + ( { model | activeFilter = filter, activeSearch = Nothing, searchInput = "", offset = 0, activeTab = ListensTab, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit 0 filter Nothing + , Nav.pushUrl model.navKey (prefix ++ "?page=1") + ] + ) + + ClearFilter -> + let + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + ( { model | activeFilter = Nothing, offset = 0, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit 0 Nothing model.activeSearch + , Nav.pushUrl model.navKey (prefix ++ "?page=1") + ] + ) + + UpdateSearchInput str -> + ( { model | searchInput = str }, Cmd.none ) + + SubmitSearch -> + let + q = + String.trim model.searchInput + + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + if String.isEmpty q then + ( { model | activeSearch = Nothing, offset = 0, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit 0 Nothing Nothing + , Nav.pushUrl model.navKey (prefix ++ "?page=1") + ] + ) + + else + ( { model | activeSearch = Just q, activeFilter = Nothing, offset = 0, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit 0 Nothing (Just q) + , Nav.pushUrl model.navKey (prefix ++ "?page=1") + ] + ) + + ClearSearch -> + let + prefix = + if String.isEmpty model.userSlug then + "/" + + else + "/u/" ++ model.userSlug + in + ( { model | searchInput = "", activeSearch = Nothing, offset = 0, loading = True } + , Cmd.batch + [ fetchListens model.userSlug model.limit 0 Nothing Nothing + , Nav.pushUrl model.navKey (prefix ++ "?page=1") + ] + ) + + HoverCover idx -> + ( { model | hoveredCover = Just idx }, Cmd.none ) + + UnhoverCover -> + ( { model | hoveredCover = Nothing }, Cmd.none ) + + ExpandSection section -> + ( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none ) + + ShowAllSection section -> + ( model, fetchSectionData model.userSlug model.statsPeriod section ) + + CollapseSection section -> + ( { model + | expandedSections = Set.remove section model.expandedSections + , loadedSections = Set.remove section model.loadedSections + } + , Cmd.none + ) + + FetchSimilar artist track -> + let + key = + artist ++ "\t" ++ track + in + if Dict.member key model.similarStates then + ( { model | similarStates = Dict.remove key model.similarStates }, Cmd.none ) + + else + ( { model | similarStates = Dict.insert key SimilarLoading model.similarStates } + , fetchSimilarTracks artist track + ) + + FetchSimilarTracks artist track result -> + let + key = + artist ++ "\t" ++ track + + newStates = + case result of + Ok tracks -> + Dict.insert key (SimilarLoaded tracks) model.similarStates + + Err err -> + Dict.insert key (SimilarError (httpErrorToString err)) model.similarStates + in + ( { model | similarStates = newStates }, Cmd.none ) + + +patchStatSection : String -> List StatsEntry -> Stats -> Stats +patchStatSection section entries stats = + case section of + "artist" -> + { stats | artists = entries } + + "track" -> + { stats | tracks = entries } + + "genre" -> + { stats | genres = entries } + + "label" -> + { stats | labels = entries } + + "year" -> + { stats | years = entries } + + _ -> + stats + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Time.every 30000 Tick + + + +-- HTTP diff --git a/src/Sync.purs b/src/Sync.purs @@ -0,0 +1,273 @@ +module Sync + ( listenBrainzUrl + , lastfmTrackToListen + , lbSync + , lbSyncLoop + , lfSync + , lfSyncLoop + ) where + +import Prelude + +import Control.Monad.Rec.Class (forever) +import Data.Argonaut (decodeJson, parseJson) +import Data.Argonaut.Core (Json, toArray, toObject, toString) +import Data.Array (length, mapMaybe, null) +import Data.Either (Either(..)) +import Data.Foldable (foldM, for_) +import Data.Int (fromString) +import Data.Maybe (Maybe(..), fromMaybe) +import Data.Time.Duration (Milliseconds(..)) +import Data.Tuple (Tuple(..)) +import Db (Connection, checkExists, getOldestTs, upsertScrobble, withTransaction) +import Effect.Aff (Aff, delay, launchAff_, makeAff, nonCanceler, try) +import Effect.Aff.AVar (AVar) +import Effect.Aff.Retry (RetryStatus(..), exponentialBackoff, limitRetries, recovering) +import Effect.Class (liftEffect) +import Effect.Exception as Exception +import Effect.Uncurried (mkEffectFn1) +import Fetch (fetch, Method(GET)) +import Fetch.Argonaut.Json (fromJson) +import Foreign.Object as Object +import JSURI (encodeURIComponent) +import Log as Log +import Metrics as Metrics +import Node.EventEmitter (EventHandle(..), on_) +import Node.HTTP.ClientRequest as Client +import Node.HTTP.IncomingMessage as IM +import Node.HTTPS as HTTPS +import Node.Stream.Aff (readableToStringUtf8) +import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) +import Unsafe.Coerce (unsafeCoerce) + +listenBrainzUrl :: String -> String +listenBrainzUrl username = "https://api.listenbrainz.org/1/user/" <> username <> "/listens" + +withRetry :: forall a. String -> Aff a -> Aff a +withRetry label action = recovering policy [ \_ _ -> pure true ] \(RetryStatus status) -> do + when (status.iterNumber > 0) + $ Log.warn + $ label <> " failed, retry attempt " <> show status.iterNumber + action + where + policy = exponentialBackoff (Milliseconds 1000.0) <> limitRetries 5 + +fetchListenBrainzUrl :: String -> Aff String +fetchListenBrainzUrl url = withRetry "ListenBrainz fetch" $ makeAff \callback -> do + req <- HTTPS.get url + + req # on_ Client.responseH \res -> do + launchAff_ do + result <- try $ readableToStringUtf8 (IM.toReadable res) + liftEffect $ case result of + Left err -> + callback (Left err) + Right body -> + if IM.statusCode res == 200 then + callback (Right body) + else + callback (Left $ Exception.error $ "ListenBrainz API returned status " <> show (IM.statusCode res)) + + let errorH = EventHandle "error" mkEffectFn1 + on_ errorH (\err -> callback (Left err)) (unsafeCoerce req) + + pure nonCanceler + +fetchLastfmPage :: String -> String -> Int -> Maybe Int -> Aff { tracks :: Array Json, totalPages :: Int } +fetchLastfmPage apiKey lfmUser page mTo = withRetry "Last.fm fetch" do + let + toParam = case mTo of + Just ts -> "&to=" <> show ts + Nothing -> "" + baseUrl = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + <> (fromMaybe lfmUser $ encodeURIComponent lfmUser) + <> "&format=json&limit=200&page=" + <> show page + <> toParam + url = baseUrl <> "&api_key=" <> apiKey + fr <- fetch url { method: GET } + if fr.status == 200 then do + json <- fromJson fr.json + let + parsed = do + obj <- toObject json + rt <- Object.lookup "recenttracks" obj >>= toObject + tracks <- Object.lookup "track" rt >>= toArray + attr <- Object.lookup "@attr" rt >>= toObject + totalPagesStr <- Object.lookup "totalPages" attr >>= toString + totalPages <- fromString totalPagesStr + pure { tracks, totalPages } + case parsed of + Just result -> pure result + Nothing -> liftEffect $ Exception.error "Last.fm: Failed to parse JSON response" # Exception.throwException + else do + liftEffect $ Exception.error ("Last.fm API returned status " <> show fr.status) # Exception.throwException + +lastfmTrackToListen :: Json -> Maybe Listen +lastfmTrackToListen json = do + obj <- toObject json + trackName <- Object.lookup "name" obj >>= toString + artistObj <- Object.lookup "artist" obj >>= toObject + artistName <- Object.lookup "#text" artistObj >>= toString + albumObj <- Object.lookup "album" obj >>= toObject + let releaseName = Object.lookup "#text" albumObj >>= toString + let + releaseMbid = do + s <- Object.lookup "mbid" albumObj >>= toString + if s == "" then Nothing else Just s + dateObj <- Object.lookup "date" obj >>= toObject + utsStr <- Object.lookup "uts" dateObj >>= toString + ts <- fromString utsStr + pure $ Listen + { listenedAt: Just ts + , trackMetadata: TrackMetadata + { trackName: Just trackName + , artistName: Just artistName + , releaseName: releaseName + , genre: Nothing + , label: Nothing + , mbidMapping: Just $ MbidMapping + { releaseMbid: releaseMbid + , caaReleaseMbid: releaseMbid + } + } + } + +recordSyncSuccess :: String -> String -> Int -> Aff Unit +recordSyncSuccess slug source n = do + liftEffect $ Metrics.incSyncRuns slug source "success" + when (n > 0) $ liftEffect $ Metrics.incSyncScrobbles slug source n + liftEffect $ Metrics.setSyncLastSuccess slug source + +lbSync :: Connection -> String -> String -> AVar Unit -> Aff Unit +lbSync conn username slug writeLock = void do + Log.info $ "Starting ListenBrainz sync for " <> username + result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100") + case result of + Left err -> do + Log.error $ "Sync fetch error: " <> Exception.message err + liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" + Right body -> + case parseJson body >>= decodeJson of + Left err -> do + Log.error $ "Sync parse error: " <> show err + liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" + Right (ListenBrainzResponse { payload: Payload { listens } }) -> do + Tuple added (Tuple minTs hitCount) <- withTransaction conn writeLock (processListens listens) + Log.info $ "ListenBrainz batch 1: added " <> show added <> ", " <> show hitCount <> " already present." + let allExist = hitCount == length listens && not (null listens) + if allExist || null listens then do + when (added > 0) $ Log.info $ "ListenBrainz sync complete. Added " <> show added <> " new scrobbles." + recordSyncSuccess slug "listenbrainz" added + else do + total <- paginateUntilDone 2 minTs added + Log.info $ "ListenBrainz sync complete. Added " <> show total <> " new scrobbles." + recordSyncSuccess slug "listenbrainz" total + where + paginateUntilDone batchNum minTs acc = case minTs of + Nothing -> + pure acc + Just ts -> do + Log.info $ "Fetching ListenBrainz batch " <> show batchNum <> " (before " <> show ts <> ")..." + result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100&max_ts=" <> show ts) + case result of + Left err -> do + Log.error $ "Sync fetch error: " <> Exception.message err + pure acc + Right body -> + case parseJson body >>= decodeJson of + Left err -> do + Log.error $ "Sync parse error: " <> show err + pure acc + Right (ListenBrainzResponse { payload: Payload { listens } }) -> do + Tuple added (Tuple newMinTs hitCount) <- withTransaction conn writeLock (processListens listens) + Log.info $ "ListenBrainz batch " <> show batchNum <> ": added " <> show added <> ", " <> show hitCount <> " already present." + let allExist = hitCount == length listens && not (null listens) + if allExist || null listens then + pure (acc + added) + else + paginateUntilDone (batchNum + 1) newMinTs (acc + added) + + processListens listens = do + s <- foldM step { added: 0, minTs: Nothing, hitCount: 0 } listens + pure $ Tuple s.added (Tuple s.minTs s.hitCount) + where + step s l@(Listen { listenedAt: Just ts }) = do + exists <- checkExists conn ts + if exists then pure s { minTs = Just ts, hitCount = s.hitCount + 1 } + else do + upsertScrobble conn l + pure s { added = s.added + 1, minTs = Just ts } + step s _ = do + Log.warn "Skipping scrobble without timestamp" + pure s + +lbSyncLoop :: Connection -> String -> String -> AVar Unit -> Aff Unit +lbSyncLoop conn username slug writeLock = forever do + delay (Milliseconds 60000.0) + lbSync conn username slug writeLock + +lfSync :: Connection -> String -> String -> String -> AVar Unit -> Aff Unit +lfSync conn apiKey lfmUser slug writeLock = do + void performLastfmSync + void performLastfmBackfill + where + performLastfmSync = do + Log.info $ "Starting Last.fm sync for " <> lfmUser + { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 Nothing + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) + Log.info $ "Last.fm page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." + let + validTracks = mapMaybe lastfmTrackToListen tracks + allExist = hitCount == length validTracks && not (null validTracks) + if allExist || totalPages <= 1 then do + when (added > 0) $ Log.info $ "Last.fm sync complete. Added " <> show added <> " new scrobbles." + recordSyncSuccess slug "lastfm" added + else do + total <- paginateLastfmUntilDone 2 totalPages Nothing added + Log.info $ "Last.fm sync complete. Added " <> show total <> " new scrobbles." + recordSyncSuccess slug "lastfm" total + + paginateLastfmUntilDone page totalPages mTo acc + | page > totalPages = pure acc + | otherwise = do + Log.info $ "Fetching Last.fm page " <> show page <> "/" <> show totalPages <> "..." + { tracks } <- fetchLastfmPage apiKey lfmUser page mTo + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) + Log.info $ "Last.fm page " <> show page <> "/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." + let + validTracks = mapMaybe lastfmTrackToListen tracks + allExist = hitCount == length validTracks && not (null validTracks) + if allExist || null tracks then pure (acc + added) + else paginateLastfmUntilDone (page + 1) totalPages mTo (acc + added) + + performLastfmBackfill = do + mOldest <- getOldestTs conn + for_ mOldest \oldestTs -> do + Log.info $ "Checking for Last.fm history before " <> show oldestTs <> "..." + { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 (Just (oldestTs - 1)) + if null tracks then + Log.info "No older Last.fm history found." + else do + Log.info $ "Backfilling " <> show totalPages <> " pages of Last.fm history before " <> show oldestTs + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) + Log.info $ "Last.fm backfill page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." + total <- paginateLastfmUntilDone 2 totalPages (Just (oldestTs - 1)) added + Log.info $ "Last.fm backfill complete. Added " <> show total <> " older scrobbles." + + processLastfmTracks tracks = do + s <- foldM step { added: 0, hitCount: 0 } (mapMaybe lastfmTrackToListen tracks) + pure $ Tuple s.added s.hitCount + where + step s l@(Listen { listenedAt: Just ts }) = do + exists <- checkExists conn ts + if exists then pure s { hitCount = s.hitCount + 1 } + else do + upsertScrobble conn l + pure s { added = s.added + 1 } + step s _ = pure s + +lfSyncLoop :: Connection -> String -> String -> String -> AVar Unit -> Aff Unit +lfSyncLoop conn apiKey lfmUser slug writeLock = forever do + delay (Milliseconds 60000.0) + lfSync conn apiKey lfmUser slug writeLock diff --git a/src/Templates.purs b/src/Templates.purs @@ -743,12 +743,7 @@ indexHtml userSlug allUsers = <> """; var app = Elm.Client.init({ - node: document.getElementById('app'), - flags: { search: window.location.search, userSlug: userSlug, allUsers: allUsers } - }); - app.ports.pushUrl.subscribe(function(url) { - var prefix = userSlug ? '/u/' + userSlug : ''; - history.pushState({}, '', prefix + url); + flags: { userSlug: userSlug, allUsers: allUsers } }); </script> </body> diff --git a/src/Types.elm b/src/Types.elm @@ -0,0 +1,149 @@ +module Types exposing (ActiveFilter, Flags, Listen, Model, Msg(..), Period(..), SimilarState(..), SimilarTrack, Stats, StatsEntry, Tab(..), UserInfo) + +import Browser +import Browser.Navigation as Nav +import Dict exposing (Dict) +import Http +import Set exposing (Set) +import Time +import Url + + +type alias UserInfo = + { slug : String + , name : String + } + + +type alias Flags = + { userSlug : String + , allUsers : List UserInfo + } + + + +-- TYPES + + +type Tab + = ListensTab + | StatsTab + | AboutTab + + +type Period + = AllTime + | LastDays Int + | CustomRange String String + + +type alias ActiveFilter = + { field : String + , value : String + } + + +type alias Listen = + { trackName : Maybe String + , artistName : Maybe String + , releaseName : Maybe String + , listenedAt : Maybe Int + , genre : Maybe String + , label : Maybe String + , releaseMbid : Maybe String + , caaReleaseMbid : Maybe String + } + + +type alias StatsEntry = + { name : String + , count : Int + } + + +type alias Stats = + { genres : List StatsEntry + , labels : List StatsEntry + , years : List StatsEntry + , artists : List StatsEntry + , tracks : List StatsEntry + } + + +type alias SimilarTrack = + { artist : String + , track : String + , score : Maybe Float + , videoUri : Maybe String + } + + +type SimilarState + = SimilarLoading + | SimilarLoaded (List SimilarTrack) + | SimilarError String + + + +-- MODEL + + +type alias Model = + { navKey : Nav.Key + , listens : List Listen + , stats : Maybe Stats + , error : Maybe String + , loading : Bool + , currentTime : Maybe Time.Posix + , failedCovers : Set String + , hoveredCover : Maybe Int + , expandedSections : Set String + , loadedSections : Set String + , offset : Int + , limit : Int + , activeTab : Tab + , activeFilter : Maybe ActiveFilter + , statsPeriod : Period + , customInput : String + , showCustomInput : Bool + , customError : Maybe String + , userSlug : String + , similarStates : Dict String SimilarState + , allUsers : List UserInfo + , searchInput : String + , activeSearch : Maybe String + } + + +type Msg + = GotListens (Result Http.Error (List Listen)) + | GotStats (Result Http.Error Stats) + | GotSectionData String (Result Http.Error (List StatsEntry)) + | Tick Time.Posix + | GotTime Time.Posix + | ImageError String + | NextPage + | PrevPage + | SwitchTab Tab + | SetStatsPeriod Period + | OpenCustomInput + | UpdateCustomInput String + | ApplyCustomPeriod + | FilterBy String String + | ClearFilter + | HoverCover Int + | UnhoverCover + | ExpandSection String + | ShowAllSection String + | CollapseSection String + | FetchSimilar String String + | FetchSimilarTracks String String (Result Http.Error (List SimilarTrack)) + | UpdateSearchInput String + | SubmitSearch + | ClearSearch + | UrlRequested Browser.UrlRequest + | UrlChanged Url.Url + + + +-- UPDATE diff --git a/src/View.elm b/src/View.elm @@ -0,0 +1,700 @@ +module View exposing (view) + +import Browser +import Dict exposing (Dict) +import Html exposing (Html, a, button, div, h1, h2, img, input, li, p, span, strong, text, ul) +import Html.Attributes as Attr exposing (class, disabled, href, placeholder, src, style, target, type_, value) +import Html.Events exposing (on, onClick, onInput) +import Json.Decode as D +import Set exposing (Set) +import Time +import Types exposing (Listen, Model, Msg(..), Period(..), SimilarState(..), SimilarTrack, Stats, StatsEntry, Tab(..), UserInfo) +import Url + + + +-- VIEW + + +view : Model -> Browser.Document Msg +view model = + { title = "scrobbler" + , body = + [ div [ class "container" ] + [ h1 [] [ text "scrobbler" ] + , div [ class "tabs" ] + [ a + [ class + ("tab-btn" + ++ (if model.activeTab == ListensTab then + " active" + + else + "" + ) + ) + , href + (if model.userSlug == "" then + "/" + + else + "/u/" ++ model.userSlug + ) + ] + [ text "listens" ] + , button + [ class + ("tab-btn" + ++ (if model.activeTab == StatsTab then + " active" + + else + "" + ) + ) + , onClick (SwitchTab StatsTab) + ] + [ text "stats" ] + , button + [ class + ("tab-btn" + ++ (if model.activeTab == AboutTab then + " active" + + else + "" + ) + ) + , onClick (SwitchTab AboutTab) + ] + [ text "about" ] + ] + , case model.activeTab of + ListensTab -> + div [] + [ div [ class "search-bar" ] + [ input + [ type_ "text" + , class "search-input" + , placeholder "search by track, artist, album or label" + , value model.searchInput + , onInput UpdateSearchInput + , onEnter SubmitSearch + ] + [] + , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ] + , case model.activeSearch of + Just _ -> + button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ] + + Nothing -> + text "" + ] + , case model.activeFilter of + Nothing -> + text "" + + Just { field, value } -> + div [ class "filter-banner" ] + [ span [ class "filter-label" ] + [ text (field ++ ": ") + , strong [] [ text value ] + ] + , button [ class "filter-clear", onClick ClearFilter ] + [ text "✕ clear" ] + ] + , renderContent model + , div [ class "pagination" ] + [ button + [ class "page-btn" + , disabled (model.offset == 0 || model.loading) + , onClick PrevPage + ] + [ text "Previous" ] + , div [ class "page-indicator" ] + [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ] + , button + [ class "page-btn" + , disabled (List.length model.listens < model.limit || model.loading) + , onClick NextPage + ] + [ text "Next" ] + ] + ] + + StatsTab -> + div [] + [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError + , renderStatsView model.expandedSections model.loadedSections model.stats + ] + + AboutTab -> + renderAboutView model.userSlug model.allUsers + ] + ] + } + + +renderContent : Model -> Html Msg +renderContent model = + if model.loading && List.isEmpty model.listens then + ul [] [ li [ class "loading" ] [ text "⏳" ] ] + + else + case model.error of + Just err -> + ul [] [ li [ class "error" ] [ text err ] ] + + Nothing -> + div [ class "tracks-with-similar" ] + (List.indexedMap + (\idx listen -> + let + artist = + Maybe.withDefault "" listen.artistName + + trackName = + Maybe.withDefault "" listen.trackName + + key = + artist ++ "\t" ++ trackName + in + div [ class "track-with-similar-container" ] + [ ul [ class "track-item" ] + [ renderListen model.userSlug model.currentTime model.failedCovers model.hoveredCover model.similarStates idx listen ] + , renderSimilarPanel model.similarStates key + ] + ) + model.listens + ) + + +renderListen : String -> Maybe Time.Posix -> Set String -> Maybe Int -> Dict String SimilarState -> Int -> Listen -> Html Msg +renderListen userSlug currentTime failedCovers hoveredCover similarStates idx listen = + let + artist = + Maybe.withDefault "" listen.artistName + + trackName = + Maybe.withDefault "" listen.trackName + + release = + Maybe.withDefault "" listen.releaseName + + key = + artist ++ "\t" ++ trackName + + mbid = + case listen.caaReleaseMbid of + Just m -> + Just m + + Nothing -> + listen.releaseMbid + + coverUrl = + "/cover?user=" + ++ userSlug + ++ "&artist=" + ++ Url.percentEncode artist + ++ "&release=" + ++ Url.percentEncode release + ++ (case mbid of + Just m -> + "&mbid=" ++ m + + Nothing -> + "" + ) + + isZoomed = + hoveredCover == Just idx + + isActive = + Dict.member key similarStates + in + li [ class "success" ] + [ div [ class "track-info" ] + [ div [ class "track-name" ] + [ button [ class "track-link", onClick (FilterBy "track" (Maybe.withDefault "Unknown Track" listen.trackName)) ] [ text (Maybe.withDefault "Unknown Track" listen.trackName) ] ] + , div [ class "track-artist" ] [ button [ class "artist-link", onClick (FilterBy "artist" artist) ] [ text artist ] ] + , div [ class "track-time" ] + [ span [] + (button [ class "album-link", onClick (FilterBy "album" release) ] [ text release ] + :: (case listen.label of + Just l -> + [ text " • " + , button [ class "label-link", onClick (FilterBy "label" l) ] [ text l ] + ] + + Nothing -> + [] + ) + ++ [ text (" • " ++ timeAgo currentTime listen.listenedAt) ] + ) + ] + ] + , div [ class "cover-wrapper" ] + [ if Set.member coverUrl failedCovers then + text "" + + else + img + [ class + ("track-cover" + ++ (if isZoomed then + " zoomed" + + else + "" + ) + ) + , src coverUrl + , Attr.alt release + , on "error" (D.succeed (ImageError coverUrl)) + , onClick + (if isZoomed then + UnhoverCover + + else + HoverCover idx + ) + ] + [] + , case listen.genre of + Just g -> + div [ class "genre-tag" ] [ text g ] + + Nothing -> + text "" + , if artist /= "" && trackName /= "" then + button + [ class + ("similar-btn" + ++ (if isActive then + " active" + + else + "" + ) + ) + , onClick (FetchSimilar artist trackName) + ] + [ text "similar" ] + + else + text "" + ] + ] + + +renderSimilarPanel : Dict String SimilarState -> String -> Html Msg +renderSimilarPanel similarStates key = + case Dict.get key similarStates of + Nothing -> + text "" + + Just SimilarLoading -> + div [ class "similar-panel" ] + [ div [ class "similar-loading" ] [ text "⏳" ] ] + + Just (SimilarError err) -> + div [ class "similar-panel" ] + [ div [ class "similar-error" ] [ text err ] ] + + Just (SimilarLoaded tracks) -> + div [ class "similar-panel" ] + [ if List.isEmpty tracks then + div [ class "similar-empty" ] [ text "no similar songs found" ] + + else + div [] (List.map renderSimilarTrack tracks) + ] + + +renderSimilarTrack : SimilarTrack -> Html Msg +renderSimilarTrack track = + div [ class "similar-track" ] + [ div [ class "similar-track-info" ] + [ div [ class "similar-track-name" ] [ text track.track ] + , div [ class "similar-track-artist" ] [ text track.artist ] + ] + , case track.score of + Just score -> + span [ class "similar-score" ] + [ text (String.fromInt (round (score * 100)) ++ "%") ] + + Nothing -> + text "" + , case track.videoUri of + Just link -> + a [ class "similar-link", href link, target "_blank" ] + [ text "▶" ] + + Nothing -> + text "" + ] + + +renderStatsView : Set String -> Set String -> Maybe Stats -> Html Msg +renderStatsView expandedSections loadedSections mStats = + case mStats of + Nothing -> + div [ class "loading" ] [ text "⏳" ] + + Just stats -> + div [] + [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" stats.artists + , renderStatSection expandedSections loadedSections Nothing "top tracks" stats.tracks + , renderStatSection expandedSections loadedSections (Just "genre") "genres" stats.genres + , renderStatSection expandedSections loadedSections (Just "label") "labels" stats.labels + , renderStatSection expandedSections loadedSections (Just "year") "years" stats.years + ] + + +renderStatSection : Set String -> Set String -> Maybe String -> String -> List StatsEntry -> Html Msg +renderStatSection expandedSections loadedSections mField title entries = + let + sectionKey = + Maybe.withDefault title mField + + expanded = + Set.member sectionKey expandedSections + + loaded = + Set.member sectionKey loadedSections + + visible = + if loaded then + entries + + else if expanded then + List.take 50 entries + + else + List.take 10 entries + + maxCount = + entries + |> List.map .count + |> List.maximum + |> Maybe.withDefault 1 + + n = + List.length entries + + footer = + if loaded then + if n > 10 then + button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + + else + text "" + + else if expanded then + if n >= 50 then + span [] + [ button [ class "show-all-btn", onClick (ShowAllSection sectionKey) ] [ text "show all" ] + , text " · " + , button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + ] + + else + button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + + else if n > 10 then + button [ class "show-all-btn", onClick (ExpandSection sectionKey) ] [ text "show more" ] + + else + text "" + in + div [ class "stats-section" ] + [ h2 [] [ text title ] + , if List.isEmpty entries then + div [ class "stats-empty" ] [ text "beyond here lies nothing" ] + + else + ul [] (List.map (renderStatEntry maxCount mField) visible) + , footer + ] + + +renderStatEntry : Int -> Maybe String -> StatsEntry -> Html Msg +renderStatEntry maxCount mField entry = + let + barPct = + entry.count * 100 // maxCount + + rowAttrs = + case mField of + Just field -> + [ class "stat-row clickable", onClick (FilterBy field entry.name) ] + + Nothing -> + [ class "stat-row" ] + in + li rowAttrs + [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] [] + , span [ class "stat-name" ] [ text entry.name ] + , span [ class "stat-count" ] [ text (String.fromInt entry.count) ] + ] + + +renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Html Msg +renderPeriodSelector current showInput customVal mError = + let + isCustom = + case current of + CustomRange _ _ -> + True + + _ -> + False + + namedBtn target label = + button + [ class + ("period-btn" + ++ (if current == target then + " active" + + else + "" + ) + ) + , onClick (SetStatsPeriod target) + ] + [ text label ] + + customBtn = + button + [ class + ("period-btn" + ++ (if isCustom then + " active" + + else + "" + ) + ) + , onClick OpenCustomInput + ] + [ text "custom" ] + + daysBtn n = + let + label = + case n of + 7 -> + "1w" + + 14 -> + "2w" + + 30 -> + "1m" + + 90 -> + "3m" + + 180 -> + "6m" + + 365 -> + "1y" + + _ -> + String.fromInt n ++ "d" + in + button + [ class + ("period-btn" + ++ (if current == LastDays n then + " active" + + else + "" + ) + ) + , onClick (SetStatsPeriod (LastDays n)) + ] + [ text label ] + in + div [] + [ div [ class "period-selector" ] + ([ namedBtn AllTime "all time", customBtn ] + ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] + ) + , if showInput || isCustom then + div [] + [ div [ class "custom-range" ] + [ input + [ type_ "text" + , class + ("custom-range-input" + ++ (if mError /= Nothing then + " error" + + else + "" + ) + ) + , placeholder "2023-01-01 2026-01-01" + , value customVal + , onInput UpdateCustomInput + ] + [] + , button [ class "period-btn", onClick ApplyCustomPeriod ] + [ text "apply" ] + ] + , case mError of + Just err -> + div [ class "custom-range-error" ] [ text err ] + + Nothing -> + text "" + ] + + else + text "" + ] + + +onEnter : msg -> Html.Attribute msg +onEnter msg = + on "keydown" + (D.field "key" D.string + |> D.andThen + (\key -> + if key == "Enter" then + D.succeed msg + + else + D.fail "not enter" + ) + ) + + + +-- HELPERS + + +timeAgo : Maybe Time.Posix -> Maybe Int -> String +timeAgo mNow mTimestamp = + case ( mNow, mTimestamp ) of + ( Just now, Just ts ) -> + let + nowSecs = + Time.posixToMillis now // 1000 + + diff = + nowSecs - ts + in + if diff < 60 then + "just now" + + else if diff < 3600 then + let + mins = + diff // 60 + in + String.fromInt mins + ++ " minute" + ++ (if mins > 1 then + "s" + + else + "" + ) + ++ " ago" + + else if diff < 86400 then + let + hours = + diff // 3600 + in + String.fromInt hours + ++ " hour" + ++ (if hours > 1 then + "s" + + else + "" + ) + ++ " ago" + + else + let + days = + diff // 86400 + in + String.fromInt days + ++ " day" + ++ (if days > 1 then + "s" + + else + "" + ) + ++ " ago" + + _ -> + "unknown time" + + +renderAboutView : String -> List UserInfo -> Html Msg +renderAboutView currentSlug allUsers = + let + otherUsers = + List.filter (\u -> u.slug /= currentSlug) allUsers + + userLink { slug, name } = + let + url = + if slug == "" then + "/" + + else + "/u/" ++ slug + in + li [] [ a [ href url ] [ text name ] ] + + extLink url label = + a [ href url, target "_blank", class "about-link" ] [ text label ] + in + div [] + [ p [ class "about-lead" ] + [ text "corpus is a self-hosted listen history proxy that syncs scrobbles from " + , extLink "https://listenbrainz.org" "ListenBrainz" + , text " and " + , extLink "https://www.last.fm" "Last.fm." + ] + , div [ class "stats-section" ] + [ h2 [] [ text "features" ] + , ul [ class "about-list" ] + [ li [] [ text "searchable listen history with pagination" ] + , li [] [ text "stats by artist, track, label, year, and genre" ] + , li [] [ text "filter listens by label, artist, genre, or year" ] + , li [] [ text "cover art from Cover Art Archive, Last.fm, and Discogs" ] + , li [] [ text "similar track discovery via cosine.club" ] + , li [] [ text "metadata enrichment via MusicBrainz, Last.fm, and Discogs" ] + ] + ] + , div [ class "stats-section" ] + [ h2 [] [ text "links" ] + , p [ class "about-meta" ] + [ div [] [ extLink "https://instagram.com/counterpoint303" "counterpoint" ] + , div [] [ extLink "https://github.com/mtmn/corpus" "github repo" ] + , div [] [ extLink "https://mtmn.name" "mtmn.name" ] + ] + ] + , if List.isEmpty otherUsers then + text "" + + else + div [ class "stats-section" ] + [ h2 [] [ text "friends" ] + , ul [ class "about-users" ] + (List.map userLink otherUsers) + ] + ] diff --git a/test/Main.purs b/test/Main.purs @@ -15,7 +15,9 @@ import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..) import Db (FilterField(..), connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats, dbBaseName, getOldestTs, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, touchGenreCheckedAt) import Data.Argonaut.Core (Json) import Foreign.Object as Object -import Main (sanitizeKey, listenBrainzUrl, lastfmTrackToListen, parseFilterField) +import Main (parseFilterField) +import Cover (sanitizeKey) +import Sync (listenBrainzUrl, lastfmTrackToListen) import S3 (getS3Url) main :: Effect Unit diff --git a/users.dhall b/users.dhall @@ -6,7 +6,6 @@ let UserConfig = , coverCacheEnabled : Bool , backupEnabled : Bool , backupIntervalHours : Natural - , initialSync : Bool } let defaults @@ -18,7 +17,6 @@ let defaults , coverCacheEnabled = True , backupEnabled = False , backupIntervalHours = 24 - , initialSync = False } in { users = @@ -37,7 +35,6 @@ in { users = with lastfmUser = Some "mtmnn" with databaseFile = "corpus-lastfm-mtmnn.db" with backupEnabled = True - with initialSync = True } ] } diff --git a/users.json b/users.json @@ -6,7 +6,6 @@ "backupIntervalHours": 24, "coverCacheEnabled": true, "databaseFile": "corpus-mtmn.db", - "initialSync": false, "listenbrainzUser": "mtmn" }, "name": "mtmn", @@ -18,7 +17,6 @@ "backupIntervalHours": 24, "coverCacheEnabled": true, "databaseFile": "corpus-lastfm-mtmnn.db", - "initialSync": true, "lastfmUser": "mtmnn" }, "name": "mtmn (last.fm)",