move from eslint to oxc (#371)

This commit is contained in:
cl.st authored and GitHub committed 2026-10-12 19:03:20 +02:00
1 parent 69acae3cb8
commit 33497d932b
56 files changed
+1181 -2551

No files matched your search

-47
View File
@@ -1,47 +0,0 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# ESLint is a tool for identifying and reporting on patterns
# found in ECMAScript/JavaScript code.
# More details at https://github.com/eslint/eslint
# and https://eslint.org
name: ESLint
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
jobs:
eslint:
name: Run eslint scanning
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install ${{ github.actor == 'dependabot[bot]' && '--no-frozen-lockfile' || '--frozen-lockfile' }}
- name: run ESLint + Prettier
run: pnpm run quality:eslint
-1
View File
@@ -105,4 +105,3 @@ jobs:
android/app/build/outputs/apk/release/shazarr-signed.apk
- run: echo Build status report=${{ job.status }}
-2
View File
@@ -62,7 +62,6 @@ jobs:
working-directory: ./android
run: chmod +x gradlew
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
@@ -92,4 +91,3 @@ jobs:
with:
name: app-bundle
path: android/app/build/outputs/apk/release/shazarr-signed.apk
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Oxc (oxlint + oxfmt)
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
oxlint:
name: Run oxlint scanning
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install ${{ github.actor == 'dependabot[bot]' && '--no-frozen-lockfile' || '--frozen-lockfile' }}
- name: Run Oxlint
run: pnpm run quality:lint
oxfmt:
name: Check formatting with oxfmt
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install ${{ github.actor == 'dependabot[bot]' && '--no-frozen-lockfile' || '--frozen-lockfile' }}
- name: Check formatting
run: pnpm run quality:format-check
+4 -4
View File
@@ -1,9 +1,9 @@
name: Playwright Tests
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
@@ -11,7 +11,7 @@ jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
name: 'Playwright Tests'
name: "Playwright Tests"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'pnpm'
cache: "pnpm"
- name: Install dependencies
run: pnpm install ${{ github.actor == 'dependabot[bot]' && '--no-frozen-lockfile' || '--frozen-lockfile' }}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/schemas/oxfmtrc.json",
"ignorePatterns": [
"**/node_modules",
"**/public",
"**/playwright-report",
"**/dist",
"**/android",
"**/ios",
"**/.git"
],
"settings": {
"typescript": {
"tsconfig": "./tsconfig.json"
}
},
"options": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "es5",
"indentWidth": 2,
"useTabs": false,
"lineEndings": "lf"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/schemas/oxlintrc.json",
"ignorePatterns": [
"**/node_modules",
"**/public",
"**/playwright-report",
"**/dist",
"**/android",
"**/ios"
],
"plugins": ["react", "jsx_a11y"],
"rules": {
"react/exhaustive-deps": "warn",
"react/forward-ref-uses-ref": "warn",
"react/jsx-key": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"jsx_a11y/alt-text": "error",
"jsx_a11y/anchor-has-content": "error",
"jsx_a11y/anchor-is-valid": "error",
"jsx_a11y/aria-activedescendant-has-tabindex": "error",
"jsx_a11y/aria-props": "error",
"jsx_a11y/aria-proptypes": "error",
"jsx_a11y/aria-role": "error",
"jsx_a11y/aria-unsupported-elements": "error",
"jsx_a11y/autocomplete-valid": "error",
"jsx_a11y/click-events-have-key-events": "error",
"jsx_a11y/control-has-associated-label": "error",
"jsx_a11y/heading-has-content": "error",
"jsx_a11y/html-has-lang": "error",
"jsx_a11y/iframe-has-title": "error",
"jsx_a11y/img-redundant-alt": "error",
"jsx_a11y/interactive-supports-focus": "error",
"jsx_a11y/label-has-associated-control": "error",
"jsx_a11y/lang": "error",
"jsx_a11y/media-has-caption": "error",
"jsx_a11y/mouse-events-have-key-events": "error",
"jsx_a11y/no-access-key": "error",
"jsx_a11y/no-autofocus": "error",
"jsx_a11y/no-distracting-elements": "error",
"jsx_a11y/no-interactive-element-to-noninteractive-role": "error",
"jsx_a11y/no-noninteractive-element-interactions": "error",
"jsx_a11y/no-noninteractive-element-to-interactive-role": "error",
"jsx_a11y/no-noninteractive-tabindex": "error",
"jsx_a11y/no-redundant-roles": "error",
"jsx_a11y/no-static-element-interactions": "error",
"jsx_a11y/role-has-required-aria-props": "error",
"jsx_a11y/role-supports-aria-props": "error",
"jsx_a11y/scope": "error",
"jsx_a11y/tabindex-no-positive": "error"
},
"settings": {
"typescript": {
"tsconfig": "./tsconfig.json"
}
}
}
+149 -62
View File
@@ -1,163 +1,250 @@
# Changelog
Shazarr notable changes.
[Keep a Changelog](http://keepachangelog.com/en/1.0.0/) format.
## 📦 0.3.7
### 🖍 Changed
* Remove alarm and reminder permission request #366
- Remove alarm and reminder permission request #366
## 📦 0.3.6
### 🚀 Added
* Add Soulsync support #358
- Add Soulsync support #358
### 🖍 Changed
* Move from Matomo to Umami
- Move from Matomo to Umami
## 📦 0.3.5
### 🚀 Added
* Add new option to auto-shazam on app open #337
* Dedup records on new tags and store previous tag dates #339
- Add new option to auto-shazam on app open #337
- Dedup records on new tags and store previous tag dates #339
## 📦 0.3.4
### 🖍 Changed
* Wait for Lidarr's artist sync to actually finish instead of guessing with a timeout
### 🖍 Changed
- Wait for Lidarr's artist sync to actually finish instead of guessing with a timeout
## 📦 0.3.3
### 🖍 Changed
* Keep tidarr and lidarr button state instead of returning to initial state
- Keep tidarr and lidarr button state instead of returning to initial state
## 📦 0.3.2
### 🚀 Added
* Use back button to close panel
- Use back button to close panel
### 🖍 Changed
* Download the APK directly on update
- Download the APK directly on update
### 🐛 Fixed
* Rewrite Lidarr album monitoring #308
- Rewrite Lidarr album monitoring #308
## 📦 0.3.1
### 🐛 Fixed
* Fix Lidarr monitored artist instead of album only #308
- Fix Lidarr monitored artist instead of album only #308
## 📦 0.3.0
### 🖍 Changed
* Results shows now as a inline card instead of a dedicated screen.
* Enhance UI for landscape mode
- Results shows now as a inline card instead of a dedicated screen.
- Enhance UI for landscape mode
## 📦 0.2.1
### 🐛 Fixed
* Fix Tidarr network error
- Fix Tidarr network error
## 📦 0.2.0
### 🚀 Added
* Lidarr button now auto seearch if API key is set #241
* Tidarr search is now integrated in the app if API key is set
- Lidarr button now auto seearch if API key is set #241
- Tidarr search is now integrated in the app if API key is set
### 🖍 Changed
* Gradle version is synched with the github tag in ci #227
- Gradle version is synched with the github tag in ci #227
### 🧹 Renovate
* Dependencies update
- Dependencies update
## 📦 0.1.6
### 🐛 Changed
* Use github tag as project version
- Use github tag as project version
## 📦 0.1.5
### 🐛 Fixed
* Fix issue with android navigation button overlap in history
- Fix issue with android navigation button overlap in history
## 📦 0.1.4
### 🐛 Fixed
* Fix issue with android navigation button overlap
- Fix issue with android navigation button overlap
### 🧹 Renovate
* Update other node dependencies
- Update other node dependencies
## 📦 0.1.3
### 🐛 Fixed
* Update tidarr search url according version 0.3.0
- Update tidarr search url according version 0.3.0
### 🧹 Renovate
* Update `Android SDK` from version 34 to 35
* Move from `npm`to `pnpm`
* Update other node dependencies
- Update `Android SDK` from version 34 to 35
- Move from `npm`to `pnpm`
- Update other node dependencies
## 📦 0.1.2
### 🧹 Renovate
* Update `capacitor` from version 6 to 7
* Update `react` from version 18 to 19
* Update other node dependencies
* Move from `react-scripts`to `vite`
- Update `capacitor` from version 6 to 7
- Update `react` from version 18 to 19
- Update other node dependencies
- Move from `react-scripts`to `vite`
## 📦 0.1.1
### 🐛 Fixed
* Fix application crash when track result does not have album information
### 🧹 Renovate
* Add Matomo cookie free and anonimyzed data
### 🐛 Fixed
- Fix application crash when track result does not have album information
### 🧹 Renovate
- Add Matomo cookie free and anonimyzed data
## 📦 0.1.0
### 🖍 Changed
* Change notification message to specify prerelease ou release update status
- Change notification message to specify prerelease ou release update status
### 🐛 Fixed
* Fix issue with track image not updating when picking an history item
* Fix readme apk download link
- Fix issue with track image not updating when picking an history item
- Fix readme apk download link
### 🧹 Renovate
* Update node deps
* Migrate capacitor from v5 to v6
* Typescript 5
* Eslint 9
- Update node deps
- Migrate capacitor from v5 to v6
- Typescript 5
- Eslint 9
## 📦 0.0.9
### 🚀 Added
* Playwright testing
* Github CI Eslint + testing
- Playwright testing
- Github CI Eslint + testing
### 🐛 Fixed
* Fix history order issue on delete / recognize offline record
- Fix history order issue on delete / recognize offline record
## 📦 0.0.8
### 🐛 Fixed
* Fix missing "not found" message
- Fix missing "not found" message
## 📦 0.0.7
### 🖍 Changed
* Update `shazam-api` node package to 0.2.0
* Remove git patch package and clean code
- Update `shazam-api` node package to 0.2.0
- Remove git patch package and clean code
### 🐛 Fixed
* Fix datetime issue on android
- Fix datetime issue on android
## 📦 0.0.6
### 🖍 Changed
* Use Lidarr in lazy mode to avoid API issue
- Use Lidarr in lazy mode to avoid API issue
Clean code - code style
## 📦 0.0.5
### 🐛 Fixed
* Fix Audio player keeps playing issue
- Fix Audio player keeps playing issue
### 🚀 Added
* Add error handling on Shazam and Lidarr queries.
- Add error handling on Shazam and Lidarr queries.
## 📦 0.0.4
### 🖍 Changed
* Remove FFmpeg
* Use JS Audio API (make song identification much faster)
- Remove FFmpeg
- Use JS Audio API (make song identification much faster)
### 🐛 Fixed
* Fix offline record
- Fix offline record
## 📦 0.0.3
### 🚀 Added
* Add app update notifications
- Add app update notifications
### 🖍 Changed
* Refacto transcoding
- Refacto transcoding
### 🐛 Fixed
* Fix Lidarr API fetches
- Fix Lidarr API fetches
## 📦 0.0.2
### 🚀 Added
* Add github page
- Add github page
### 🖍 Changed
* Improve ffmpeg
- Improve ffmpeg
### 🐛 Fixed
* Fix "not found" response
- Fix "not found" response
## 📦 0.0.1
### 🚀 Added
* Initial project package
- Initial project package
+14 -1
View File
@@ -14,6 +14,7 @@ Shazarr is a mobile app (Android, iOS) providing Shazam song recognition with de
## Features
### Core
- Audio microphone capture and song recognition using reverse Shazam API with [node-shazam-api](https://github.com/asivery/node-shazam-api)
- Listen on streaming apps: Spotify, Apple Music and Deezer
- Show lyrics for recognized tracks
@@ -22,6 +23,7 @@ Shazarr is a mobile app (Android, iOS) providing Shazam song recognition with de
- Privacy: No login, no tracking, no mandatory API key
### Music Services Integration
- **Lidarr**: Auto-search and download discovered albums via API
- **Tidarr**: Search Tidal content in-app, queue tracks or full album downloads, add to Tidal favorites
- **SoulSync**: Add to wishlist to trigger automatic download via API
@@ -41,10 +43,12 @@ Get last release :
Configure Shazarr to connect with your music automation services:
### Services
- **Lidarr URL**: `http://<lidarr-web-ui-url>` (optional API key for auto-search, falls back to browser)
- **Tidarr URL**: `http://<tidarr-web-ui-url>` (optional API key for auto-search)
### SoulSync
- **SoulSync URL**: `http://<soulsync-ip>:8008`
- **SoulSync API Key**: `your-api-key` (generated from SoulSync settings: Settings > API)
- **SoulSync Profile ID**: `1` (optional, defaults to profile 1)
@@ -52,6 +56,7 @@ Configure Shazarr to connect with your music automation services:
> **Note**: When configured with API key, Shazarr will automatically search and add tracks/albums to your SoulSync wishlist, triggering automatic downloads based on your SoulSync configuration.
### Custom Service
- **Custom service URL**: `http://<service-url>?query=` (e.g., `http://my-service.local/search?q=`)
- **Custom service name**: Display name for the button (e.g., "My Music Service")
@@ -74,6 +79,7 @@ Configure Shazarr to connect with your music automation services:
6. After a few seconds, the app will be installed on your iPad/iPhone
## Roadmap
- [x] Android app
- [x] Record history: access last shazamed songs
- [x] Offline record: record without API access, recognize song later
@@ -81,10 +87,13 @@ Configure Shazarr to connect with your music automation services:
- [ ] iOS app: find a way to distribute packaged app without using app store and without have to clone and build it
## Development
- Android + iOS mobile app (ReactJS + Ionic/Capacitor)
### Watch mode
Start app in watch mode :
```sh
pnpm run start # Listening on http://localhost:3000/
pnpm run android:live # Listening on http://localhost:8100/ + emulate
@@ -92,11 +101,15 @@ pnpm run ios:live # Listening on http://localhost:8100/ + emulate
```
### Build
Build APK with Android studio:
```
pnpm run android:build
```
Build ios app with Xcode:
```
make ios/build
```
@@ -108,9 +121,9 @@ If you like this project you can support here:
<a href="https://www.buymeacoffee.com/clst" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="40" width="160"></a>
## Credits
- Big thanks to the [node-shazam-api](https://github.com/asivery/node-shazam-api) team for the awesome works and reactivness 👏💪🙏
- UI inspiration : https://github.com/codrops/ShazamButtonEffect
- See Lidarr project: https://github.com/linuxserver/docker-lidarr
- See Tidarr project: https://github.com/cstaelen/tidarr
- See SoulSync project: https://github.com/Nezreka/SoulSync
+1 -1
View File
@@ -5,4 +5,4 @@
"npmClient": "pnpm",
"webDir": "dist",
"cordova": {}
}
}
-63
View File
@@ -1,63 +0,0 @@
import js from "@eslint/js";
import jsxA11Y from "eslint-plugin-jsx-a11y";
import reactHooksEslint from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh'
import simpleImportSort from "eslint-plugin-simple-import-sort";
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: [
"**/node_modules",
"**/public",
"**/playwright-report",
"**/dist",
"**/android",
"**/ios",
], },
{
extends: [js.configs.recommended,
...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooksEslint,
'react-refresh': reactRefresh,
"jsx-a11y": jsxA11Y,
"simple-import-sort": simpleImportSort,
},
rules: {
...reactHooksEslint.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
"simple-import-sort/imports": [
"error",
{
groups: [
// Packages `react` related packages come first.
["^react", "^@?\\w"],
// Internal packages.
["^(@|components)(/.*|$)"],
// Internal packages.
["^(assets/js)(/.*|$)"],
// Side effect imports.
["^\\u0000"],
// Parent imports. Put `..` last.
["^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Other relative imports. Put same-folder imports and `.` last.
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
// Style imports.
["^.+\\.?(css)$"],
],
},
],
"simple-import-sort/exports": "error",
},
},
)
+5 -7
View File
@@ -1,19 +1,17 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Shazarr - Unoffical Shazam app" />
<title>Shazarr - Unoffical Shazam app</title>
</head>
</head>
<body>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</body>
</html>
+1 -1
View File
@@ -4,4 +4,4 @@
"capacitor": {}
},
"type": "react"
}
}
+39 -45
View File
@@ -1,9 +1,28 @@
{
"name": "shazarr-app",
"private": true,
"engines": {
"node": ">=18",
"npm": ">=10"
"scripts": {
"start": "vite dev",
"build": "tsc && vite build",
"preview": "vite preview",
"ionic:serve": "pnpm run start",
"ios:open": "ionic capacitor open ios",
"ios:sync": "npx cap sync ios",
"ios:live": "ionic capacitor run ios -l --external --port 5173",
"android:open": "ionic capacitor open android",
"android:sync": "npx cap sync android",
"android:live": "ionic capacitor run android -l --external --port 5173",
"quality:deadcode": "ts-prune | (! grep -v 'used in module')",
"quality:depcheck": "npx depcheck",
"quality:lint": "oxlint",
"quality:lint-fix": "oxlint --fix",
"quality:format": "oxfmt",
"quality:format-check": "oxfmt --check",
"testing:run": "docker run --rm -it -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test'",
"testing:ui": "npx playwright test --ui",
"testing:ci": "docker run --rm -e CI=true -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test'",
"testing:show-report": "docker run --rm -it -p 9323:9323 -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble npx playwright show-report ./testing/playwright-report --host 0.0.0.0",
"testing:update-snapshots": "docker run --rm -it -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test --update-snapshots'"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
@@ -21,7 +40,6 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@typescript-eslint/parser": "^8.71.1",
"@uidotdev/usehooks": "^2.4.1",
"capacitor-voice-recorder": "^7.0.6",
"node": "^26.10.0",
@@ -32,45 +50,33 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.5.2",
"@eslint/js": "^10.0.1",
"@ionic/cli": "^7.2.1",
"@playwright/test": "^1.63.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"depcheck": "^1.4.7",
"eslint": "^10.12.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-simple-import-sort": "^14.0.0",
"globals": "^17.13.0",
"prettier": "^3.9.9",
"oxfmt": "^0.72.0",
"oxlint": "^1.87.0",
"ts-prune": "^0.10.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.1",
"vite": "^8.3.3"
},
"scripts": {
"start": "vite dev",
"build": "tsc && vite build",
"preview": "vite preview",
"ionic:serve": "pnpm run start",
"ios:open": "ionic capacitor open ios",
"ios:sync": "npx cap sync ios",
"ios:live": "ionic capacitor run ios -l --external --port 5173",
"android:open": "ionic capacitor open android",
"android:sync": "npx cap sync android",
"android:live": "ionic capacitor run android -l --external --port 5173",
"quality:deadcode": "ts-prune | (! grep -v 'used in module')",
"quality:depcheck": "npx depcheck",
"quality:eslint": "eslint .",
"quality:eslint-fix": "eslint --fix .",
"testing:run": "docker run --rm -it -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test'",
"testing:ui": "npx playwright test --ui",
"testing:ci": "docker run --rm -e CI=true -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test'",
"testing:show-report": "docker run --rm -it -p 9323:9323 -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble npx playwright show-report ./testing/playwright-report --host 0.0.0.0",
"testing:update-snapshots": "docker run --rm -it -v .:/srv -w /srv mcr.microsoft.com/playwright:v1.63.0-noble sh -c 'npm i -g pnpm@10.28.2 && pnpm install --no-frozen-lockfile && npx playwright test --update-snapshots'"
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=18",
"npm": ">=10"
},
"pnpm": {
"overrides": {
@@ -93,17 +99,5 @@
"uuid": ">=11.1.1",
"yaml": ">=1.10.3"
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
+423 -1877
View File
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -2,14 +2,7 @@ import { useEffect, useState } from "react";
import { Device } from "@capacitor/device";
import { ThemeProvider } from "@emotion/react";
import styled from "@emotion/styled";
import {
Box,
Container,
createTheme,
CssBaseline,
Stack,
Typography,
} from "@mui/material";
import { Box, Container, createTheme, CssBaseline, Stack, Typography } from "@mui/material";
import { AlertHandler } from "./components/Common/Alert/AlertHandler";
import { Analytics } from "./components/Common/Analytics";
+7 -4
View File
@@ -8,7 +8,12 @@ import { useShazarrProvider } from "../../Shazarr/useShazarr";
import ApiErrorAlert from "./Alert";
export const AlertHandler = () => {
const { recordingStatus, recordingError, showInlineResult, actions: { dismissInlineResult, setOpenResultDate } } = useShazarrProvider();
const {
recordingStatus,
recordingError,
showInlineResult,
actions: { dismissInlineResult, setOpenResultDate },
} = useShazarrProvider();
const { isNetworkConnected } = useConfigProvider();
const { history } = useHistoryProvider();
@@ -33,9 +38,7 @@ export const AlertHandler = () => {
onOpen={handleOpenResult}
/>
)}
{!isNetworkConnected && recordingStatus === "inactive" && (
<ApiErrorAlert severity="info" />
)}
{!isNetworkConnected && recordingStatus === "inactive" && <ApiErrorAlert severity="info" />}
{recordingError && recordingStatus === "inactive" && (
<ApiErrorAlert message={recordingError} />
)}
@@ -1,16 +1,16 @@
import { useEffect } from 'react';
import { useEffect } from "react";
const WEBSITE_ID = '24631c72-dafb-4fb7-8992-8e719aec2c7a';
const SCRIPT_URL = 'https://um.cl-st.me/x.js';
const WEBSITE_ID = "24631c72-dafb-4fb7-8992-8e719aec2c7a";
const SCRIPT_URL = "https://um.cl-st.me/x.js";
export function Analytics() {
useEffect(() => {
if (import.meta.env.DEV) return;
const script = document.createElement('script');
const script = document.createElement("script");
script.defer = true;
script.src = SCRIPT_URL;
script.setAttribute('data-website-id', WEBSITE_ID);
script.setAttribute("data-website-id", WEBSITE_ID);
document.head.appendChild(script);
return () => {
+1 -1
View File
@@ -1 +1 @@
export { Analytics } from './Analytics';
export { Analytics } from "./Analytics";
+10 -35
View File
@@ -5,7 +5,7 @@ import { Preferences } from "@capacitor/preferences";
import { SHAZARR_STORE_KEY } from "../../constant";
import { ConfigContext, ConfigFieldsType,ConfigStoreType } from "./context";
import { ConfigContext, ConfigFieldsType, ConfigStoreType } from "./context";
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<ConfigStoreType | null>(null);
@@ -79,20 +79,13 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
[loadForm],
);
async function sendNotification(
name: string,
prerelease: boolean,
apkUrl?: string,
) {
async function sendNotification(name: string, prerelease: boolean, apkUrl?: string) {
let allowed = false;
const { display: currentPerm } =
await LocalNotifications.checkPermissions();
const { display: currentPerm } = await LocalNotifications.checkPermissions();
if (currentPerm !== "granted") {
const { display: requestStatus } =
await LocalNotifications.requestPermissions();
const { display: requestStatus } = await LocalNotifications.requestPermissions();
if (requestStatus === "granted") {
allowed = true;
@@ -104,10 +97,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (!allowed) return;
LocalNotifications.addListener("localNotificationActionPerformed", () => {
window.open(
apkUrl ??
`https://github.com${import.meta.env.VITE_REPO_API_URL}/releases`,
);
window.open(apkUrl ?? `https://github.com${import.meta.env.VITE_REPO_API_URL}/releases`);
});
LocalNotifications.show({
notifications: [
@@ -129,26 +119,16 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
`https://api.github.com/repos${import.meta.env.VITE_REPO_API_URL}/releases`,
);
const data = await response.json();
const latestVersion = data[0].tag_name.substring(
1,
data[0].tag_name.length,
);
const latestVersion = data[0].tag_name.substring(1, data[0].tag_name.length);
const currentVersion = import.meta.env.VITE_CURRENT_VERSION.substring(
1,
data[0].tag_name.length,
);
if (
latestVersion !== currentVersion &&
latestVersion > currentVersion
) {
if (latestVersion !== currentVersion && latestVersion > currentVersion) {
const apkAsset = data[0].assets?.find((asset: { name: string }) =>
asset.name.endsWith(".apk"),
);
sendNotification(
data[0].tag_name,
data[0].prerelease,
apkAsset?.browser_download_url,
);
sendNotification(data[0].tag_name, data[0].prerelease, apkAsset?.browser_download_url);
}
} catch (e) {
console.log("fetch github issue", e);
@@ -160,9 +140,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
async function init() {
const { value: store } = await Preferences.get({ key: SHAZARR_STORE_KEY });
const storeData: ConfigStoreType =
store && store !== "undefined"
? JSON.parse(store)
: ({} as ConfigStoreType);
store && store !== "undefined" ? JSON.parse(store) : ({} as ConfigStoreType);
setConfig(storeData);
loadForm(storeData);
}
@@ -189,8 +167,5 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
},
};
return (
<ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
);
return <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>;
}
+1 -3
View File
@@ -32,6 +32,4 @@ type ConfigContextType = {
};
};
export const ConfigContext = createContext<ConfigContextType>(
{} as ConfigContextType,
);
export const ConfigContext = createContext<ConfigContextType>({} as ConfigContextType);
+1 -2
View File
@@ -4,8 +4,7 @@ export const data = {
MISSING_PERMISSION: "Access to microphone denied.",
DEVICE_CANNOT_VOICE_RECORD: "This device does not allow voice recording.",
NOT_SUPPORTED_OS_VERSION: "A feature seems to not being supported by OS.",
SHAZAM_API_ERROR:
"An error occured while connecting to shazam service. Try later.",
SHAZAM_API_ERROR: "An error occured while connecting to shazam service. Try later.",
SHAZARR_NOT_FOUND: "Song not found. Get closer ;)",
};
+5 -22
View File
@@ -33,11 +33,7 @@ const SERVICE_GROUPS = [
id: "lidarr",
name: "Lidarr",
icon: (
<img
src={lidarrLogo}
alt="Lidarr"
style={{ width: 24, height: 24, borderRadius: "4px" }}
/>
<img src={lidarrLogo} alt="Lidarr" style={{ width: 24, height: 24, borderRadius: "4px" }} />
),
fields: ["lidarr_url", "lidarr_api_key"],
},
@@ -110,8 +106,7 @@ export default function Config({ open, onClose }: Props) {
const handleAccordionChange = (groupId: string) => {
setExpanded((prev) => {
const isCurrentlyExpanded =
prev.includes(groupId);
const isCurrentlyExpanded = prev.includes(groupId);
if (isCurrentlyExpanded) {
return prev.filter((id) => id !== groupId);
} else {
@@ -130,10 +125,7 @@ export default function Config({ open, onClose }: Props) {
return group.fields.some((field) => config?.[field as keyof typeof config]);
};
const renderField = (
fieldKey: string,
fieldConfig: ConfigFieldsType[keyof ConfigFieldsType]
) => {
const renderField = (fieldKey: string, fieldConfig: ConfigFieldsType[keyof ConfigFieldsType]) => {
if (fieldConfig.type === "checkbox") {
return (
<FormControlLabel
@@ -144,12 +136,7 @@ export default function Config({ open, onClose }: Props) {
width: "100%",
}}
labelPlacement="start"
control={
<Switch
name={fieldKey}
defaultChecked={Boolean(fieldConfig.value)}
/>
}
control={<Switch name={fieldKey} defaultChecked={Boolean(fieldConfig.value)} />}
label={fieldConfig.placeholder}
/>
);
@@ -253,11 +240,7 @@ export default function Config({ open, onClose }: Props) {
},
}}
>
<Stack
direction="row"
spacing={1}
sx={{ width: "100%", alignItems: "center" }}
>
<Stack direction="row" spacing={1} sx={{ width: "100%", alignItems: "center" }}>
<Box
sx={{
width: 24,
+1 -5
View File
@@ -10,11 +10,7 @@ import { useShazarrProvider } from "../Shazarr/useShazarr";
import { HistoryItem } from "./context";
import { useHistoryProvider } from "./useHistory";
export default function HistoryCard({
item,
}: {
item: HistoryItem;
}) {
export default function HistoryCard({ item }: { item: HistoryItem }) {
const {
actions: { searchOfflineRecord, setOpenResultDate },
} = useShazarrProvider();
+1 -4
View File
@@ -31,10 +31,7 @@ export default function HistoryList({ open, onClose }: Props) {
>
<Container maxWidth="xs" sx={{ padding: "0 0.5rem" }}>
{history?.map((item, index) => (
<HistoryCard
item={item}
key={`history-item${index}`}
/>
<HistoryCard item={item} key={`history-item${index}`} />
))}
<Button
startIcon={<Close />}
+3 -12
View File
@@ -46,17 +46,10 @@ export function HistoryProvider({ children }: { children: ReactNode }) {
const existing = list[existingIndex];
const merged: HistoryItem = {
...item,
previousDates: [
existing.date,
...(existing.previousDates || []),
],
previousDates: [existing.date, ...(existing.previousDates || [])],
};
return [
...list.slice(0, existingIndex),
...list.slice(existingIndex + 1),
merged,
];
return [...list.slice(0, existingIndex), ...list.slice(existingIndex + 1), merged];
});
}
@@ -72,7 +65,5 @@ export function HistoryProvider({ children }: { children: ReactNode }) {
},
};
return (
<HistoryContext.Provider value={value}>{children}</HistoryContext.Provider>
);
return <HistoryContext.Provider value={value}>{children}</HistoryContext.Provider>;
}
+1 -3
View File
@@ -18,6 +18,4 @@ type HistoryContextType = {
};
};
export const HistoryContext = createContext<HistoryContextType>(
{} as HistoryContextType,
);
export const HistoryContext = createContext<HistoryContextType>({} as HistoryContextType);
+1 -2
View File
@@ -26,8 +26,7 @@ const Note = () => {
left: Math2.randomCeil(leftPos),
width: newScale,
height: newScale,
animation:
"noteAnimation 1000ms cubic-bezier(0.47, 0, 0.745, 0.715) forwards",
animation: "noteAnimation 1000ms cubic-bezier(0.47, 0, 0.745, 0.715) forwards",
opacity: 0,
position: "absolute",
textShadow: "10px 10px #000",
+6 -16
View File
@@ -19,9 +19,7 @@ export default function ShazarrButton() {
return (
<Box sx={{ position: "relative" }}>
{recordingStatus !== "inactive" ? (
<NotesAnimate
duration={recordingStatus === "recording" ? RECORD_DURATION : 0}
/>
<NotesAnimate duration={recordingStatus === "recording" ? RECORD_DURATION : 0} />
) : null}
<ListenButton>
@@ -34,17 +32,11 @@ export default function ShazarrButton() {
}
}}
>
<Round
isAnimate={recordingStatus === "recording" || shazarrLoading}
>
<Round isAnimate={recordingStatus === "recording" || shazarrLoading}>
{recordingStatus === "searching" ? (
<MoreHorizOutlined
style={{ transform: "scale(1.6)", opacity: 0.5 }}
/>
<MoreHorizOutlined style={{ transform: "scale(1.6)", opacity: 0.5 }} />
) : recordingStatus === "recording" ? (
<GraphicEqIcon
style={{ transform: "scale(1.6)", opacity: 0.5 }}
/>
<GraphicEqIcon style={{ transform: "scale(1.6)", opacity: 0.5 }} />
) : (
<>
<img
@@ -96,14 +88,12 @@ const Round = styled.div<{ isAnimate: boolean }>`
margin-left: -3.5rem;
margin-top: -3.5rem;
z-index: 0;
animation: ${({ isAnimate }) =>
isAnimate ? "button-animate 2s infinite linear" : "none"};
animation: ${({ isAnimate }) => (isAnimate ? "button-animate 2s infinite linear" : "none")};
transition: transform 300ms ease;
}
img {
animation: ${({ isAnimate }) =>
isAnimate ? "button-animate 2s infinite linear" : "none"};
animation: ${({ isAnimate }) => (isAnimate ? "button-animate 2s infinite linear" : "none")};
}
@keyframes button-animate {
+9 -18
View File
@@ -23,8 +23,7 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
const [openResultDate, setOpenResultDate] = useState<number>();
const [shazarrLoading, setShazarrLoading] = useState(false);
const [recordingError, setRecordingError] = useState<ErrorCodeType | undefined>();
const [recordingStatus, setRecordingStatus] =
useState<RecordingStatusType>("inactive");
const [recordingStatus, setRecordingStatus] = useState<RecordingStatusType>("inactive");
const { config, isNetworkConnected } = useConfigProvider();
@@ -62,7 +61,6 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
}
};
function handleTesting(date: number) {
const audioData = mockRecordBase64;
setAudio(audioData);
@@ -122,12 +120,10 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
const startRecording = async (duration: number = RECORD_DURATION) => {
setRecordingError(undefined);
try {
const { value: hasPerm } =
await VoiceRecorder.hasAudioRecordingPermission();
const { value: hasPerm } = await VoiceRecorder.hasAudioRecordingPermission();
if (!hasPerm) {
const { value: resultRequestPerm } =
await VoiceRecorder.requestAudioRecordingPermission();
const { value: resultRequestPerm } = await VoiceRecorder.requestAudioRecordingPermission();
if (!resultRequestPerm) return;
}
@@ -143,9 +139,8 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
value: { recordDataBase64 },
} = await VoiceRecorder.stopRecording();
const audioData = import.meta.env.VITE_STAGE === "testing"
? mockRecordBase64
: recordDataBase64;
const audioData =
import.meta.env.VITE_STAGE === "testing" ? mockRecordBase64 : recordDataBase64;
if (!audioData) return;
@@ -165,7 +160,6 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
}
processTranscoding(audioData);
}, duration);
}
} catch (err) {
@@ -174,11 +168,11 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
if (import.meta.env.VITE_STAGE === "testing") {
handleTesting(Date.now());
return;
};
}
resetSearch();
setRecordingError("ERROR_RECORDING" as never);
};
}
};
const hasCheckedLaunchConfig = useRef(false);
@@ -192,8 +186,7 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
if (recordingStatus !== "inactive") return;
setTimeout(() => startRecording(), 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const searchOfflineRecord = (item: HistoryItem) => {
@@ -221,7 +214,5 @@ export function ShazarrProvider({ children }: { children: ReactNode }) {
},
};
return (
<ShazarrContext.Provider value={value}>{children}</ShazarrContext.Provider>
);
return <ShazarrContext.Provider value={value}>{children}</ShazarrContext.Provider>;
}
+21 -17
View File
@@ -1,7 +1,19 @@
import { useEffect, useMemo } from "react";
import { App } from "@capacitor/app";
import { Close } from "@mui/icons-material";
import { Box, Button, Divider, Drawer, Stack, Table, TableBody, TableCell, TableContainer, TableRow, useMediaQuery } from "@mui/material";
import {
Box,
Button,
Divider,
Drawer,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
useMediaQuery,
} from "@mui/material";
import { ShazamProvider } from "shazam-api/dist/types";
import { useConfigProvider } from "../Config/useConfig";
@@ -52,11 +64,7 @@ export default function ShazarrResults() {
if (!data) return null;
return (
<Drawer
anchor="bottom"
open={!!data}
onClose={onClose}
>
<Drawer anchor="bottom" open={!!data} onClose={onClose}>
<Box sx={{ p: 2, display: "flex", gap: 2, flexDirection: isLandscape ? "row" : "column" }}>
<Box sx={{ flex: "1 1 0" }}>
<CardResult data={data} onClose={onClose} />
@@ -120,25 +128,21 @@ export default function ShazarrResults() {
/>
)}
{(config?.lidarr_url || config?.tidarr_url || config?.soulsync_url || (config?.custom_service_url && config?.custom_service_name)) && (
<Divider />
)}
{(config?.lidarr_url ||
config?.tidarr_url ||
config?.soulsync_url ||
(config?.custom_service_url && config?.custom_service_name)) && <Divider />}
<Box sx={{ textAlign: "center" }}>
{data?.hub?.providers?.map(
(provider: ShazamProvider, index: number) => (
{data?.hub?.providers?.map((provider: ShazamProvider, index: number) => (
<StreamProviderButton
key={`provider-${index}`}
uri={provider.actions?.[0]?.uri}
type={provider.type}
/>
),
)}
))}
{data?.myshazam?.apple?.actions?.[0]?.uri && (
<StreamProviderButton
uri={data.myshazam.apple.actions?.[0]?.uri}
type="APPLE"
/>
<StreamProviderButton uri={data.myshazam.apple.actions?.[0]?.uri} type="APPLE" />
)}
</Box>
+42 -22
View File
@@ -48,7 +48,11 @@ export type LidarrAutoSearchResult =
| { success: true; status?: "queued" | "wanted" | "available" }
| { success: false; message: string };
async function request<T>(config: LidarrConfig, path: string, options: RequestInit = {}): Promise<T> {
async function request<T>(
config: LidarrConfig,
path: string,
options: RequestInit = {},
): Promise<T> {
const baseUrl = `${config.url.replace(/\/$/, "")}/api/v1`;
// Build URL via URL object — Capacitor double-encodes string concatenation
const [pathname, search] = path.split("?");
@@ -56,12 +60,19 @@ async function request<T>(config: LidarrConfig, path: string, options: RequestIn
if (search) {
for (const part of search.split("&")) {
const eq = part.indexOf("=");
url.searchParams.set(eq === -1 ? part : part.slice(0, eq), eq === -1 ? "" : part.slice(eq + 1));
url.searchParams.set(
eq === -1 ? part : part.slice(0, eq),
eq === -1 ? "" : part.slice(eq + 1),
);
}
}
const res = await fetch(url.toString(), {
...options,
headers: { "Content-Type": "application/json", "X-Api-Key": config.apiKey, ...(options.headers ?? {}) },
headers: {
"Content-Type": "application/json",
"X-Api-Key": config.apiKey,
...(options.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
@@ -85,16 +96,22 @@ async function lookupArtist(config: LidarrConfig, term: string): Promise<LidarrA
return request<LidarrArtistLookup[]>(config, `/artist/lookup?term=${term}`);
}
async function lookupAlbumByMbid(config: LidarrConfig, mbid: string): Promise<LidarrAlbumLookup | null> {
async function lookupAlbumByMbid(
config: LidarrConfig,
mbid: string,
): Promise<LidarrAlbumLookup | null> {
const results = await request<LidarrAlbumLookup[]>(config, `/album/lookup?term=lidarr:${mbid}`);
return results[0] ?? null;
}
async function ensureArtist(
config: LidarrConfig,
artist: LidarrArtistLookup
): Promise<{ success: true; artistId: number; created: boolean } | { success: false; message: string }> {
if (!artist?.foreignArtistId) return { success: false, message: "Invalid artist: missing foreignArtistId" };
artist: LidarrArtistLookup,
): Promise<
{ success: true; artistId: number; created: boolean } | { success: false; message: string }
> {
if (!artist?.foreignArtistId)
return { success: false, message: "Invalid artist: missing foreignArtistId" };
const found = await findArtistByMbid(config, artist.foreignArtistId);
if (found?.id) return { success: true, artistId: found.id, created: false };
@@ -106,8 +123,10 @@ async function ensureArtist(
]);
if (!rootFolders?.length) return { success: false, message: "No Lidarr root folders configured" };
if (!qualityProfiles?.length) return { success: false, message: "No Lidarr quality profiles found" };
if (!metadataProfiles?.length) return { success: false, message: "No Lidarr metadata profiles found" };
if (!qualityProfiles?.length)
return { success: false, message: "No Lidarr quality profiles found" };
if (!metadataProfiles?.length)
return { success: false, message: "No Lidarr metadata profiles found" };
// Prefer a profile that includes all release types (singles, live, etc.)
const inclusiveProfile = metadataProfiles.find((p) => /^(none|all|standard)$/i.test(p.name));
@@ -154,7 +173,7 @@ type LidarrCommand = {
async function waitForArtistRefresh(
config: LidarrConfig,
artistId: number,
timeoutMs = 60_000
timeoutMs = 60_000,
): Promise<void> {
const started = Date.now();
// Start fast to catch small discographies quickly, then back off — large
@@ -163,7 +182,7 @@ async function waitForArtistRefresh(
while (Date.now() - started < timeoutMs) {
const commands = await request<LidarrCommand[]>(config, "/command");
const refresh = commands.find(
(c) => c.name === "RefreshArtist" && c.body?.artistIds?.includes(artistId)
(c) => c.name === "RefreshArtist" && c.body?.artistIds?.includes(artistId),
);
if (!refresh || refresh.status === "completed" || refresh.status === "failed") return;
await new Promise((r) => setTimeout(r, pollIntervalMs));
@@ -192,7 +211,7 @@ async function ensureMonitored<T extends { monitored: boolean }>(
resource: "artist" | "album",
id: number,
attempts = 4,
delayMs = 2_000
delayMs = 2_000,
): Promise<T> {
let entity = await request<T>(config, `/${resource}/${id}`);
for (let i = 0; i < attempts && !entity.monitored; i++) {
@@ -209,7 +228,7 @@ async function ensureMonitored<T extends { monitored: boolean }>(
async function triggerOrAddAlbum(
config: LidarrConfig,
album: LidarrAlbum,
wasAlreadyMonitored: boolean
wasAlreadyMonitored: boolean,
): Promise<LidarrAutoSearchResult> {
const status = getAlbumStatus(album);
if (status === "available") return { success: true, status };
@@ -224,7 +243,7 @@ async function triggerOrAddAlbum(
async function finalizeAlbum(
config: LidarrConfig,
ensured: { artistId: number; created: boolean },
findAlbum: (albums: LidarrAlbum[]) => LidarrAlbum | undefined
findAlbum: (albums: LidarrAlbum[]) => LidarrAlbum | undefined,
): Promise<LidarrAutoSearchResult | null> {
if (ensured.created) await waitForArtistRefresh(config, ensured.artistId);
@@ -259,18 +278,18 @@ function releaseGroupRank(candidate: LidarrAlbumLookup): number {
async function resolveAlbumLookupByIsrc(
config: LidarrConfig,
isrc: string,
normalizedArtist: string
normalizedArtist: string,
): Promise<LidarrAlbumLookup | null> {
const releaseGroups = await getReleaseGroupsByIsrc(isrc).catch(() => []);
if (releaseGroups.length === 0) return null;
const lookups = await Promise.all(
releaseGroups.map((g) => lookupAlbumByMbid(config, g.id).catch(() => null))
releaseGroups.map((g) => lookupAlbumByMbid(config, g.id).catch(() => null)),
);
const candidates = lookups.filter(
(a): a is LidarrAlbumLookup =>
!!a?.artist && normalize(a.artist.artistName) === normalizedArtist
!!a?.artist && normalize(a.artist.artistName) === normalizedArtist,
);
if (candidates.length === 0) return null;
@@ -280,7 +299,7 @@ async function resolveAlbumLookupByIsrc(
async function lidarrAutoSearchByMbid(
config: LidarrConfig,
albumLookup: LidarrAlbumLookup
albumLookup: LidarrAlbumLookup,
): Promise<LidarrAutoSearchResult | null> {
if (!albumLookup.artist) return null;
@@ -288,7 +307,7 @@ async function lidarrAutoSearchByMbid(
if (!ensured.success) return null;
return finalizeAlbum(config, ensured, (albums) =>
albums.find((a) => a.foreignAlbumId === albumLookup.foreignAlbumId)
albums.find((a) => a.foreignAlbumId === albumLookup.foreignAlbumId),
);
}
@@ -297,9 +316,10 @@ export async function lidarrAutoSearch(
apiKey: string,
albumTitle: string,
artistName: string,
isrc?: string
isrc?: string,
): Promise<LidarrAutoSearchResult> {
if (!albumTitle || !artistName) return { success: false, message: "Missing album or artist name" };
if (!albumTitle || !artistName)
return { success: false, message: "Missing album or artist name" };
const config: LidarrConfig = { url, apiKey };
const normalizedAlbum = normalize(cleanAlbumTitle(albumTitle));
@@ -329,7 +349,7 @@ export async function lidarrAutoSearch(
if (!ensured.success) return { success: false, message: ensured.message };
const result = await finalizeAlbum(config, ensured, (albums) =>
albums.find((a) => normalize(a.title) === normalizedAlbum)
albums.find((a) => normalize(a.title) === normalizedAlbum),
);
return result ?? { success: false, message: "Album not found" };
} catch (e: unknown) {
+2 -4
View File
@@ -35,13 +35,11 @@ async function request<T>(path: string): Promise<T> {
// An ISRC identifies a recording, which can appear on several releases
// (single, album, compilation...). Each release belongs to one release-group,
// which is the stable "album" identity Lidarr expects for unambiguous lookup.
export async function getReleaseGroupsByIsrc(
isrc: string
): Promise<MusicBrainzReleaseGroup[]> {
export async function getReleaseGroupsByIsrc(isrc: string): Promise<MusicBrainzReleaseGroup[]> {
if (!isrc?.trim()) return [];
const data = await request<MusicBrainzRecordingSearchResponse>(
`/recording?query=isrc:${encodeURIComponent(isrc)}&fmt=json&inc=releases%2Brelease-groups`
`/recording?query=isrc:${encodeURIComponent(isrc)}&fmt=json&inc=releases%2Brelease-groups`,
);
const releaseGroups = new Map<string, MusicBrainzReleaseGroup>();
+11 -14
View File
@@ -17,7 +17,7 @@ export type SoulsyncQueueResult =
async function request<T>(
config: SoulsyncConfig,
path: string,
options: RequestInit = {}
options: RequestInit = {},
): Promise<{
success: boolean;
data?: T;
@@ -53,9 +53,7 @@ async function request<T>(
const text = await res.text();
try {
const errorData = JSON.parse(text);
throw new Error(
`SoulSync API error (${res.status}): ${errorData.error?.message || text}`
);
throw new Error(`SoulSync API error (${res.status}): ${errorData.error?.message || text}`);
} catch {
throw new Error(`SoulSync API error (${res.status}): ${text}`);
}
@@ -65,10 +63,9 @@ async function request<T>(
if (!content) return { success: true } as const;
return JSON.parse(content);
} catch (e) {
throw new Error(
`SoulSync unreachable: ${e instanceof Error ? e.message : "Unknown error"}`,
{ cause: e }
);
throw new Error(`SoulSync unreachable: ${e instanceof Error ? e.message : "Unknown error"}`, {
cause: e,
});
}
}
@@ -80,7 +77,7 @@ export async function soulsyncSearchTracks(
query: string,
apiKey?: string,
profileId?: number,
limit: number = 5
limit: number = 5,
): Promise<{
success: boolean;
tracks?: Array<{
@@ -130,7 +127,7 @@ export async function soulsyncAddTrackToWishlist(
trackTitle: string,
artistName: string,
apiKey?: string,
profileId?: number
profileId?: number,
): Promise<SoulsyncQueueResult> {
const config: SoulsyncConfig = { url, apiKey, profileId };
@@ -141,7 +138,7 @@ export async function soulsyncAddTrackToWishlist(
`${artistName} - ${trackTitle}`,
apiKey,
profileId,
1
1,
);
if (!searchResult.success || !searchResult.tracks?.length) {
@@ -191,7 +188,7 @@ export async function soulsyncAddAlbumToWishlist(
albumTitle: string,
artistName: string,
apiKey?: string,
profileId?: number
profileId?: number,
): Promise<SoulsyncQueueResult> {
const config: SoulsyncConfig = { url, apiKey, profileId };
@@ -202,7 +199,7 @@ export async function soulsyncAddAlbumToWishlist(
`${artistName} - ${albumTitle}`,
apiKey,
profileId,
25
25,
);
if (!searchResult.success || !searchResult.tracks?.length) {
@@ -216,7 +213,7 @@ export async function soulsyncAddAlbumToWishlist(
const albumTracks = searchResult.tracks.filter(
(t) =>
normalize(t.album) === normalize(albumTitle) &&
t.artists.some((a) => normalize(a) === normalize(artistName))
t.artists.some((a) => normalize(a) === normalize(artistName)),
);
if (albumTracks.length === 0) {
+24 -13
View File
@@ -37,7 +37,7 @@ export function tidalCoverUrl(cover: string): string {
async function request<T>(
config: TidarrConfig,
path: string,
options: RequestInit = {}
options: RequestInit = {},
): Promise<T> {
let res: Response;
try {
@@ -91,7 +91,11 @@ async function fetchAlbumTracks(config: TidarrConfig, albumId: number): Promise<
return data?.rows?.[1]?.modules?.[0]?.pagedList?.items?.map((t) => t.item) ?? [];
}
async function findTrack(config: TidarrConfig, albumId: number, trackTitle: string): Promise<TidalTrack | null> {
async function findTrack(
config: TidarrConfig,
albumId: number,
trackTitle: string,
): Promise<TidalTrack | null> {
const tracks = await fetchAlbumTracks(config, albumId);
return tracks.find((t) => normalize(t.title) === normalize(trackTitle)) ?? null;
}
@@ -105,21 +109,23 @@ export async function tidarrSearchAlbums(
apiKey: string,
artist: string,
album: string,
trackTitle: string
trackTitle: string,
): Promise<TidalAlbum[]> {
const config: TidarrConfig = { url, apiKey };
const data = await request<{ albums?: { items: TidalAlbum[] } }>(
config,
`/proxy/tidal/v2/search?query=${encodeURIComponent(`${artist} ${album}`)}&${TIDAL_PARAMS}&limit=10`
`/proxy/tidal/v2/search?query=${encodeURIComponent(`${artist} ${album}`)}&${TIDAL_PARAMS}&limit=10`,
);
const normalizedAlbum = normalize(album);
const normalizedTrack = normalize(trackTitle);
const candidates = (data?.albums?.items ?? []).filter((a) => normalize(a.title) === normalizedAlbum);
const candidates = (data?.albums?.items ?? []).filter(
(a) => normalize(a.title) === normalizedAlbum,
);
const results = await Promise.all(
candidates.map(async (a) => {
const tracks = await fetchAlbumTracks(config, a.id);
return tracks.some((t) => normalize(t.title) === normalizedTrack) ? a : null;
})
}),
);
return results.filter((a): a is TidalAlbum => a !== null);
}
@@ -128,23 +134,28 @@ export async function tidarrFavoriteTrack(
url: string,
apiKey: string,
albumId: number,
trackTitle: string
trackTitle: string,
): Promise<TidarrQueueResult> {
const config: TidarrConfig = { url, apiKey };
try {
const settings = await fetchSettings(config);
if (!settings) return { success: false, message: "Could not get Tidal credentials from Tidarr settings" };
if (!settings)
return { success: false, message: "Could not get Tidal credentials from Tidarr settings" };
const track = await findTrack(config, albumId, trackTitle);
if (!track) return { success: false, message: "Track not found in album" };
const res = await fetch(
`${config.url.replace(/\/$/, "")}/proxy/tidal/v1/users/${settings.auth.user_id}/favorites/tracks?countryCode=${settings.auth.country_code}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Api-Key": config.apiKey },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Api-Key": config.apiKey,
},
body: `trackId=${track.id}`,
}
},
);
if (!res.ok) return { success: false, message: `Tidal error (${res.status}): ${await res.text()}` };
if (!res.ok)
return { success: false, message: `Tidal error (${res.status}): ${await res.text()}` };
return { success: true, message: "Added to Tidal favorites!" };
} catch (e) {
return catchError(e, "Favorite failed");
@@ -155,7 +166,7 @@ export async function tidarrQueueAlbum(
url: string,
apiKey: string,
album: TidalAlbum,
artistName: string
artistName: string,
): Promise<TidarrQueueResult> {
const config: TidarrConfig = { url, apiKey };
try {
@@ -184,7 +195,7 @@ export async function tidarrQueueTrack(
url: string,
apiKey: string,
albumId: number,
trackTitle: string
trackTitle: string,
): Promise<TidarrQueueResult> {
const config: TidarrConfig = { url, apiKey };
try {
+1 -3
View File
@@ -21,6 +21,4 @@ type ShazarrContextType = {
};
};
export const ShazarrContext = createContext<ShazarrContextType>(
{} as ShazarrContextType,
);
export const ShazarrContext = createContext<ShazarrContextType>({} as ShazarrContextType);
+4 -19
View File
@@ -1,15 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { App } from "@capacitor/app";
import {
PauseCircleFilled,
PlayCircleFilled,
Refresh,
} from "@mui/icons-material";
import {
Button,
CardActions,
CardMedia,
} from "@mui/material";
import { PauseCircleFilled, PlayCircleFilled, Refresh } from "@mui/icons-material";
import { Button, CardActions, CardMedia } from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
@@ -26,10 +18,7 @@ export default function CardResult({ data, onClose }: { data: ShazamTrack; onClo
[data],
);
const sample = useMemo(
() => (sampleURI ? new Audio(sampleURI) : undefined),
[sampleURI],
);
const sample = useMemo(() => (sampleURI ? new Audio(sampleURI) : undefined), [sampleURI]);
function togglePlayPause() {
if (!sample) return;
@@ -99,11 +88,7 @@ export default function CardResult({ data, onClose }: { data: ShazamTrack; onClo
{isPlaying ? "Pause" : "Play"}
</Button>
&nbsp;
<Button
onClick={onClose}
variant="outlined"
color="warning"
>
<Button onClick={onClose} variant="outlined" color="warning">
<Refresh style={{ transform: "rotate(-180deg)" }} />
</Button>
</Box>
+21 -9
View File
@@ -1,5 +1,14 @@
import { Close, RemoveRedEye } from "@mui/icons-material";
import { Box, ButtonBase, Card, CardActions, CardContent, CardMedia, IconButton, Typography } from "@mui/material";
import {
Box,
ButtonBase,
Card,
CardActions,
CardContent,
CardMedia,
IconButton,
Typography,
} from "@mui/material";
import { ShazamTrack } from "shazam-api/dist/types";
import { ImageWithFallback } from "../../Common/ImageWithFallback";
@@ -16,19 +25,22 @@ export default function InlineResultCard({ track, onDismiss, onOpen }: Props) {
<Card sx={{ display: "flex", alignItems: "center" }}>
<CardMedia sx={{ lineHeight: 0, flex: "0 0 auto" }}>
<ButtonBase onClick={onOpen}>
<ImageWithFallback
height="70"
width="70"
alt=""
src={track?.images?.coverart || ""}
/>
<ImageWithFallback height="70" width="70" alt="" src={track?.images?.coverart || ""} />
</ButtonBase>
</CardMedia>
<CardContent sx={{ padding: "0.5rem 1rem", flex: "1 1 0" }}>
<ButtonBase sx={{ textAlign: "left", width: "100%", justifyContent: "flex-start" }} onClick={onOpen}>
<ButtonBase
sx={{ textAlign: "left", width: "100%", justifyContent: "flex-start" }}
onClick={onOpen}
>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.2 }}>
<strong>{track.title}</strong>
{track.subtitle && <><br />{track.subtitle}</>}
{track.subtitle && (
<>
<br />
{track.subtitle}
</>
)}
</Typography>
</ButtonBase>
</CardContent>
+3 -9
View File
@@ -43,7 +43,7 @@ export default function LidarrButton({
if (!apiKey) {
window.open(
`${url}/add/search?term=${encodeURIComponent(`${albumTitle} ${artistName}`)}`,
"_blank"
"_blank",
);
return;
}
@@ -52,7 +52,7 @@ export default function LidarrButton({
const result = await lidarrAutoSearch(url, apiKey, albumTitle, artistName, isrc);
if (result.success) {
const s = result.status ?? "queued";
switch (s){
switch (s) {
case "available":
setSuccessMessage("Exists in Lidarr");
break;
@@ -92,13 +92,7 @@ export default function LidarrButton({
<img src={lidarrLogo} alt="" width="32" height="32" />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
}
color={status === "success" ? "success" : status === "error" ? "warning" : "primary"}
onClick={handleClick}
>
<strong>{label}</strong>
+3 -15
View File
@@ -79,20 +79,14 @@ export default function SoulsyncButton({
let result: SoulsyncQueueResult;
if (type === "track") {
result = await soulsyncAddTrackToWishlist(
url,
trackTitle,
artistName,
apiKey,
profileId
);
result = await soulsyncAddTrackToWishlist(url, trackTitle, artistName, apiKey, profileId);
} else {
result = await soulsyncAddAlbumToWishlist(
url,
effectiveAlbumTitle,
artistName,
apiKey,
profileId
profileId,
);
}
@@ -136,13 +130,7 @@ export default function SoulsyncButton({
<SoulsyncLogoIcon />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
}
color={status === "success" ? "success" : status === "error" ? "warning" : "primary"}
onClick={handleClick}
data-testid="soulsync-button"
>
+1 -3
View File
@@ -1,4 +1,3 @@
import { Check, MicOutlined, NetworkPing } from "@mui/icons-material";
import { Chip, CircularProgress } from "@mui/material";
@@ -6,8 +5,7 @@ import { useConfigProvider } from "../../Config/useConfig";
import { useShazarrProvider } from "../useShazarr";
export default function StatusChip() {
const { shazarrLoading, showInlineResult, recordingStatus } =
useShazarrProvider();
const { shazarrLoading, showInlineResult, recordingStatus } = useShazarrProvider();
const { isNetworkConnected } = useConfigProvider();
return (
@@ -21,14 +21,8 @@ export default function StreamProviderButton({
startIcon={<Headphones />}
onClick={handleClick}
style={{
background:
type === "SPOTIFY"
? "#18d860"
: type === "DEEZER"
? "#a239ff"
: "#fff",
color:
type === "DEEZER" ? "#fff" : type === "APPLE" ? "#fa586a" : "#212121",
background: type === "SPOTIFY" ? "#18d860" : type === "DEEZER" ? "#a239ff" : "#fff",
color: type === "DEEZER" ? "#fff" : type === "APPLE" ? "#fa586a" : "#212121",
fontWeight: "bold",
margin: "0.25rem",
}}
+34 -27
View File
@@ -66,10 +66,7 @@ export default function TidarrButton({
async function handleClick() {
if (!apiKey) {
window.open(
`${url}/search/${encodeURIComponent(`${trackTitle} ${artistName}`)}`,
"_blank"
);
window.open(`${url}/search/${encodeURIComponent(`${trackTitle} ${artistName}`)}`, "_blank");
return;
}
@@ -128,19 +125,9 @@ export default function TidarrButton({
fullWidth
disabled={!isNetworkConnected || status === "loading"}
startIcon={
status === "loading" ? (
<CircularProgress size={20} color="inherit" />
) : (
<AlbumStyled />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
status === "loading" ? <CircularProgress size={20} color="inherit" /> : <AlbumStyled />
}
color={status === "success" ? "success" : status === "error" ? "warning" : "primary"}
onClick={handleClick}
>
<strong>{label}</strong>
@@ -150,7 +137,12 @@ export default function TidarrButton({
<span>
<IconButton
color="primary"
onClick={() => window.open(`${url}/search/${encodeURIComponent(`${trackTitle} ${artistName}`)}`, "_blank")}
onClick={() =>
window.open(
`${url}/search/${encodeURIComponent(`${trackTitle} ${artistName}`)}`,
"_blank",
)
}
disabled={!isNetworkConnected}
sx={{ border: 2, borderRadius: 1 }}
>
@@ -161,12 +153,7 @@ export default function TidarrButton({
)}
</Stack>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
fullWidth
maxWidth="xs"
>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Choose an album</DialogTitle>
<DialogContent sx={{ padding: 1 }}>
<Stack spacing={1}>
@@ -192,10 +179,20 @@ export default function TidarrButton({
<Typography variant="body2" sx={{ fontWeight: "bold" }} noWrap>
{album.title}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: "block" }}>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block" }}
>
{album.artists?.[0]?.name}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: "block" }}>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block" }}
>
{album.releaseDate?.slice(0, 4)} · {album.numberOfTracks} tracks
</Typography>
</Box>
@@ -215,7 +212,12 @@ export default function TidarrButton({
variant="text"
startIcon={<Download fontSize="small" />}
onClick={() => handleQueue(album, "track")}
sx={{ flex: 1, borderRadius: 0, borderLeft: "1px solid", borderColor: "divider" }}
sx={{
flex: 1,
borderRadius: 0,
borderLeft: "1px solid",
borderColor: "divider",
}}
>
Track
</Button>
@@ -224,7 +226,12 @@ export default function TidarrButton({
variant="text"
color="secondary"
onClick={() => handleQueue(album, "favorite")}
sx={{ borderRadius: 0, borderLeft: "1px solid", borderColor: "divider", px: 1.5 }}
sx={{
borderRadius: 0,
borderLeft: "1px solid",
borderColor: "divider",
px: 1.5,
}}
>
<Favorite fontSize="small" />
</Button>
+1 -5
View File
@@ -1,8 +1,4 @@
export const b64toBlob = (
b64Data: string,
contentType = "",
sliceSize = 512,
) => {
export const b64toBlob = (b64Data: string, contentType = "", sliceSize = 512) => {
const byteCharacters = atob(b64Data);
const byteArrays = [];
+1 -3
View File
@@ -3,9 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
<React.StrictMode>
<App />
+8 -13
View File
@@ -1,16 +1,12 @@
import test, { expect } from "@playwright/test";
test("Critical: Should be able to record, recognize and display result", async ({
page,
}) => {
test("Critical: Should be able to record, recognize and display result", async ({ page }) => {
page.goto("/");
// Home testing
await expect(page.getByText("Shazarr")).toBeVisible();
await expect(page.getByText("Offroad")).toBeVisible();
await expect(page.getByText("Ready")).toBeVisible();
await expect(
page.getByRole("button", { name: "Configuration" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Configuration" })).toBeVisible();
await expect(page).toHaveScreenshot();
// Run song recognition
@@ -34,21 +30,20 @@ test("Critical: Should be able to record, recognize and display result", async (
await expect(page).toHaveScreenshot();
// Dismiss inline card (close button, NOT delete from history)
await page.getByTestId("inline-result-card").getByRole("button", { name: "Dismiss result" }).click();
await page
.getByTestId("inline-result-card")
.getByRole("button", { name: "Dismiss result" })
.click();
await expect(page.getByTestId("inline-result-card")).not.toBeVisible();
// Expect to be on main screen with Records button visible
await expect(page.getByText("Ready")).toBeVisible();
await expect(
page.getByRole("button", { name: "Records" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Records" })).toBeVisible();
// Open history drawer
await page.getByRole("button", { name: "Records" }).click();
await expect(page.getByTestId("history-item")).toBeVisible();
await expect(page.getByTestId("history-item")).toContainText(
"Chillin' - Modjo",
);
await expect(page.getByTestId("history-item")).toContainText("Chillin' - Modjo");
// Click history item to open Result drawer
await page.getByTestId("history-item").getByRole("button").first().click();
+4 -6
View File
@@ -88,9 +88,7 @@ test("History: Should be able to remove 2 items", async ({ page }) => {
await expect(page.getByText(expectedHistory[2])).toBeVisible();
});
test("History: Should be able to consult 2 different items", async ({
page,
}) => {
test("History: Should be able to consult 2 different items", async ({ page }) => {
// Load localstorage
gotoWithLocalStorage("/", page);
@@ -102,9 +100,9 @@ test("History: Should be able to consult 2 different items", async ({
// Click on history item
await page.getByTestId("history-item").nth(0).getByRole("button").first().click();
await expect(
page.getByText("Chillin'ModjoPlay AlbumModjo"),
).toHaveScreenshot({ maxDiffPixelRatio: 0.15 });
await expect(page.getByText("Chillin'ModjoPlay AlbumModjo")).toHaveScreenshot({
maxDiffPixelRatio: 0.15,
});
// Close result — the history panel stays open underneath, no need to reopen it
await page.getByRole("button", { name: "Close" }).click();
+94 -33
View File
@@ -8,14 +8,8 @@ const LIDARR_API_KEY = "test-lidarr-api-key";
async function gotoWithConfig(page: Page, config: Record<string, string>) {
await page.addInitScript(
({ config, history }) => {
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.config",
JSON.stringify(config),
);
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.history",
JSON.stringify(history),
);
window.localStorage.setItem("CapacitorStorage.com.shazarr.config", JSON.stringify(config));
window.localStorage.setItem("CapacitorStorage.com.shazarr.history", JSON.stringify(history));
},
{ config, history: historyFixtures },
);
@@ -36,7 +30,13 @@ async function openModjoResult(page: Page) {
}
const DEFAULT_ARTIST = { id: 1, artistName: "Szymon", foreignArtistId: "abc-123" };
const DEFAULT_ALBUM = { id: 10, title: "Blue Coloured Mountain", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } };
const DEFAULT_ALBUM = {
id: 10,
title: "Blue Coloured Mountain",
artistId: 1,
monitored: false,
statistics: { percentOfTracks: 0 },
};
function mockLidarrRoutes(
page: Page,
@@ -65,17 +65,33 @@ function mockLidarrRoutes(
if (idMatch && method === "GET") {
const [, kind, id] = idMatch;
const store = kind === "artist" ? artists : albums;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(store.get(Number(id))) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(store.get(Number(id))),
});
} else if (idMatch && method === "PUT") {
const [, kind, id] = idMatch;
const store = kind === "artist" ? artists : albums;
const body = route.request().postDataJSON();
store.set(Number(id), body);
await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify(body) });
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify(body),
});
} else if (url.includes("/api/v1/artist") && !url.includes("lookup") && method === "GET") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([...artists.values()]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([...artists.values()]),
});
} else if (url.includes("/api/v1/artist/lookup")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(overrides.artistLookup ?? [DEFAULT_ARTIST]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(overrides.artistLookup ?? [DEFAULT_ARTIST]),
});
} else if (url.includes("/api/v1/artist") && method === "POST") {
const body = route.request().postDataJSON();
overrides.onArtistCreate?.(body);
@@ -83,20 +99,48 @@ function mockLidarrRoutes(
// Real Lidarr often doesn't honor monitored: true on create — simulate
// that so ensureArtistMonitored's verify-then-retry loop is exercised.
artists.set(id, { ...body, id, monitored: false });
await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ id }) });
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id }),
});
} else if (url.includes("/api/v1/album") && url.includes("artistId=")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([...albums.values()]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([...albums.values()]),
});
} else if (url.includes("/api/v1/qualityprofile")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: 1, name: "Any" }]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Any" }]),
});
} else if (url.includes("/api/v1/metadataprofile")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: 1, name: "Standard" }]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Standard" }]),
});
} else if (url.includes("/api/v1/rootfolder")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ path: "/music" }]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ path: "/music" }]),
});
} else if (url.includes("/api/v1/command") && method === "GET") {
// No RefreshArtist commands pending — waitForArtistRefresh returns immediately.
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
} else if (url.includes("/api/v1/command")) {
await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ id: 99 }) });
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id: 99 }),
});
} else {
await route.continue();
}
@@ -122,9 +166,7 @@ test("Lidarr: Without API key — opens browser search", async ({ page }) => {
await page.waitForTimeout(200);
await ctx.unroute(`${LIDARR_URL}/**`);
expect(capturedUrl).toEqual(
`${LIDARR_URL}/add/search?term=Blue%20Coloured%20Mountain%20Szymon`,
);
expect(capturedUrl).toEqual(`${LIDARR_URL}/add/search?term=Blue%20Coloured%20Mountain%20Szymon`);
});
test("Lidarr: With API key — calls API and shows success", async ({ page }) => {
@@ -139,10 +181,18 @@ test("Lidarr: With API key — calls API and shows success", async ({ page }) =>
});
});
test("Lidarr: With API key — matches album despite (Remastered) suffix in title", async ({ page }) => {
test("Lidarr: With API key — matches album despite (Remastered) suffix in title", async ({
page,
}) => {
// Modjo fixture has album "Modjo (Remastered)" — Lidarr stores it as "Modjo"
const modjoArtist = { id: 1, artistName: "Modjo", foreignArtistId: "modjo-123" };
const modjoAlbum = { id: 20, title: "Modjo", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } };
const modjoAlbum = {
id: 20,
title: "Modjo",
artistId: 1,
monitored: false,
statistics: { percentOfTracks: 0 },
};
await mockLidarrRoutes(page, {
artists: [modjoArtist],
artistLookup: [modjoArtist],
@@ -158,10 +208,18 @@ test("Lidarr: With API key — matches album despite (Remastered) suffix in titl
});
});
test("Lidarr: With API key — newly added artist's matched album is explicitly monitored, others left untouched", async ({ page }) => {
test("Lidarr: With API key — newly added artist's matched album is explicitly monitored, others left untouched", async ({
page,
}) => {
// Artist doesn't exist locally yet; ensureArtist creates it with monitor: "none",
// so Lidarr populates albums unmonitored — only the matched album gets PUT-monitored.
const otherAlbum = { id: 11, title: "Some Other Album", artistId: 1, monitored: false, statistics: { percentOfTracks: 0 } };
const otherAlbum = {
id: 11,
title: "Some Other Album",
artistId: 1,
monitored: false,
statistics: { percentOfTracks: 0 },
};
let artistPostBody: { monitored?: boolean; addOptions?: { monitor?: string } } | undefined;
const { albums } = await mockLidarrRoutes(page, {
@@ -191,7 +249,9 @@ test("Lidarr: With API key — newly added artist's matched album is explicitly
expect(albums.get(otherAlbum.id)?.monitored).toBe(false);
});
test("Lidarr: With API key — monitors an already-existing artist's unmonitored album before searching", async ({ page }) => {
test("Lidarr: With API key — monitors an already-existing artist's unmonitored album before searching", async ({
page,
}) => {
// Artist already exists locally with the album present but unmonitored (e.g. user
// added it manually before) — Shazarr must monitor it before triggering the search.
const { albums } = await mockLidarrRoutes(page, { albums: [DEFAULT_ALBUM] });
@@ -208,15 +268,16 @@ test("Lidarr: With API key — monitors an already-existing artist's unmonitored
expect(albums.get(DEFAULT_ALBUM.id)?.monitored).toBe(true);
});
test("Lidarr: With API key — shows error when album not found for existing artist", async ({ page }) => {
test("Lidarr: With API key — shows error when album not found for existing artist", async ({
page,
}) => {
// Artist found locally but the identified album isn't in its album list.
await mockLidarrRoutes(page, { albums: [] });
await gotoWithConfig(page, { lidarr_url: LIDARR_URL, lidarr_api_key: LIDARR_API_KEY });
await openYakuzaResult(page);
await page.getByRole("button", { name: "Download with Lidarr" }).click();
await expect(
page.getByRole("button", { name: "Album not found in Lidarr" }),
).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("button", { name: "Album not found in Lidarr" })).toBeVisible({
timeout: 10000,
});
});
+2 -8
View File
@@ -1,8 +1,6 @@
import test, { expect } from "@playwright/test";
test("Offline: Should save record for further recognition", async ({
page,
}) => {
test("Offline: Should save record for further recognition", async ({ page }) => {
await page.context().setOffline(false);
await page.goto("/");
await page.evaluate(() => window.localStorage.clear());
@@ -18,15 +16,11 @@ test("Offline: Should save record for further recognition", async ({
// Run song recognition and cut internet connection
await page.getByTestId("record-button").click();
// Open history panel
await page.getByRole("button", { name: "Records" }).click();
await expect(page.getByTestId("history-item")).toHaveCount(1);
await expect(
page
.getByTestId("history-item")
.locator("div")
.filter({ hasText: "Offline record - Not" }),
page.getByTestId("history-item").locator("div").filter({ hasText: "Offline record - Not" }),
).toHaveCount(1);
// Set online
+10 -26
View File
@@ -2,11 +2,7 @@ import test, { expect, Page } from "@playwright/test";
import { gotoWithLocalStorage, waitForImgLoaded } from "./utils/helpers";
async function testExternalServiceButtons(
label: string,
url: string,
page: Page,
) {
async function testExternalServiceButtons(label: string, url: string, page: Page) {
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
const ctx = page.context();
@@ -25,9 +21,7 @@ async function testExternalServiceButtons(
await expect(await capturedUrl).toEqual(url);
}
test("Options: Should see options panel and use service buttons", async ({
page,
}) => {
test("Options: Should see options panel and use service buttons", async ({ page }) => {
await gotoWithLocalStorage("/", page);
// Open config panel
@@ -40,12 +34,8 @@ test("Options: Should see options panel and use service buttons", async ({
await page.getByText("Custom Service").click();
// Fill fields and save
await page
.getByPlaceholder("Lidarr URL (http://...)")
.fill("http://lidarr.server.docker");
await page
.getByPlaceholder("Tidarr URL (http://...)")
.fill("http://tidarr.server.docker");
await page.getByPlaceholder("Lidarr URL (http://...)").fill("http://lidarr.server.docker");
await page.getByPlaceholder("Tidarr URL (http://...)").fill("http://tidarr.server.docker");
await page
.getByPlaceholder("Custom service (http://...?")
.fill("http://custom.server.docker?query=");
@@ -53,9 +43,7 @@ test("Options: Should see options panel and use service buttons", async ({
await page.getByRole("button", { name: "Save configuration" }).click();
await expect(
page.getByPlaceholder("Lidarr URL (http://...)"),
).not.toBeVisible();
await expect(page.getByPlaceholder("Lidarr URL (http://...)")).not.toBeVisible();
// Go to result page
await page.getByRole("button", { name: "Records" }).click();
@@ -64,9 +52,9 @@ test("Options: Should see options panel and use service buttons", async ({
await waitForImgLoaded(page);
await page.waitForTimeout(1000);
await expect(page.getByText("Download with Lidarr")).toHaveScreenshot(
{ maxDiffPixelRatio: 0.05 },
);
await expect(page.getByText("Download with Lidarr")).toHaveScreenshot({
maxDiffPixelRatio: 0.05,
});
// Test button availability on result screen
await testExternalServiceButtons(
@@ -92,9 +80,7 @@ test("Options: Should see options panel and use service buttons", async ({
await expect(page.getByRole("dialog")).toHaveScreenshot();
});
test("Options: Auto-listen on launch starts recording automatically", async ({
page,
}) => {
test("Options: Auto-listen on launch starts recording automatically", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.config",
@@ -107,9 +93,7 @@ test("Options: Auto-listen on launch starts recording automatically", async ({
await expect(page.getByTestId("inline-result-card")).toBeVisible();
});
test("Options: Auto-listen disabled by default does not start recording", async ({
page,
}) => {
test("Options: Auto-listen disabled by default does not start recording", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Ready")).toBeVisible();
+17 -12
View File
@@ -8,14 +8,8 @@ const SOULSYNC_API_KEY = "test-soulsync-api-key";
async function gotoWithConfig(page: Page, config: Record<string, string>) {
await page.addInitScript(
({ config, history }) => {
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.config",
JSON.stringify(config),
);
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.history",
JSON.stringify(history),
);
window.localStorage.setItem("CapacitorStorage.com.shazarr.config", JSON.stringify(config));
window.localStorage.setItem("CapacitorStorage.com.shazarr.history", JSON.stringify(history));
},
{ config, history: historyFixtures },
);
@@ -88,7 +82,10 @@ function mockSoulsyncNotFound(page: Page) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "No tracks found" } }),
body: JSON.stringify({
success: false,
error: { code: "NOT_FOUND", message: "No tracks found" },
}),
});
} else if (url.includes("/api/v1/wishlist") && method === "POST") {
await route.fulfill({
@@ -140,7 +137,9 @@ test("SoulSync: With API key — adds track to wishlist and shows success", asyn
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-track").click();
// Skip "Searching" check - too fast in CI, goes straight to success
await expect(page.getByTestId("soulsync-button")).toContainText(/Added|added|wishlist/i, { timeout: 20000 });
await expect(page.getByTestId("soulsync-button")).toContainText(/Added|added|wishlist/i, {
timeout: 20000,
});
});
test("SoulSync: With API key — adds album to wishlist and shows success", async ({ page }) => {
@@ -153,7 +152,10 @@ test("SoulSync: With API key — adds album to wishlist and shows success", asyn
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-album").click();
// Skip "Searching" check - too fast in CI, goes straight to success
await expect(page.getByTestId("soulsync-button")).toContainText(/Added|tracks from|to SoulSync/i, { timeout: 20000 });
await expect(page.getByTestId("soulsync-button")).toContainText(
/Added|tracks from|to SoulSync/i,
{ timeout: 20000 },
);
});
test("SoulSync: With API key — shows error when track not found", async ({ page }) => {
@@ -166,5 +168,8 @@ test("SoulSync: With API key — shows error when track not found", async ({ pag
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-track").click();
// Skip "Searching" check - too fast in CI, goes straight to error
await expect(page.getByTestId("soulsync-button")).toContainText(/not found|No match|Error|unreachable/i, { timeout: 20000 });
await expect(page.getByTestId("soulsync-button")).toContainText(
/not found|No match|Error|unreachable/i,
{ timeout: 20000 },
);
});
+21 -19
View File
@@ -26,14 +26,8 @@ const MOCK_SETTINGS = {
async function gotoWithConfig(page: Page, config: Record<string, string>) {
await page.addInitScript(
({ config, history }) => {
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.config",
JSON.stringify(config),
);
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.history",
JSON.stringify(history),
);
window.localStorage.setItem("CapacitorStorage.com.shazarr.config", JSON.stringify(config));
window.localStorage.setItem("CapacitorStorage.com.shazarr.history", JSON.stringify(history));
},
{ config, history: historyFixtures },
);
@@ -47,23 +41,32 @@ async function openYakuzaResult(page: Page) {
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
}
function mockTidarrRoutes(
page: Page,
overrides: { search?: unknown; albumTracks?: unknown } = {},
) {
function mockTidarrRoutes(page: Page, overrides: { search?: unknown; albumTracks?: unknown } = {}) {
return page.route(`${TIDARR_URL}/**`, async (route) => {
const url = route.request().url();
if (url.includes("/proxy/tidal/v2/search")) {
const body = overrides.search ?? { albums: { items: [MOCK_ALBUM] } };
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
} else if (url.includes("/proxy/tidal/v1/pages/album")) {
const body = overrides.albumTracks ?? {
rows: [null, { modules: [{ pagedList: { items: [{ item: MOCK_TRACK }] } }] }],
};
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
} else if (url.includes("/api/settings")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_SETTINGS) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_SETTINGS),
});
} else if (url.includes("/api/save") && route.request().method() === "POST") {
await route.fulfill({ status: 201, body: "Created" });
} else if (url.includes("/proxy/tidal/v1/users/12345/favorites/tracks")) {
@@ -114,8 +117,7 @@ test("Tidarr: With API key — shows error when no albums found", async ({ page
await openYakuzaResult(page);
await page.getByRole("button", { name: "Download with Tidarr" }).click();
await expect(
page.getByRole("button", { name: "Track not found on Tidal" }),
).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("button", { name: "Track not found on Tidal" })).toBeVisible({
timeout: 10000,
});
});
+1 -4
View File
@@ -9,10 +9,7 @@ export async function gotoWithLocalStorage(url: string, page: Page) {
const stringifiedData = JSON.stringify(historyFixtures);
await page.evaluate(
(stringifiedData) =>
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.history",
stringifiedData,
),
window.localStorage.setItem("CapacitorStorage.com.shazarr.history", stringifiedData),
stringifiedData,
);
// Reload page with
+1 -4
View File
@@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+1 -1
View File
@@ -16,7 +16,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+3 -3
View File
@@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
@@ -12,4 +12,4 @@ export default defineConfig({
define: {
global: {},
},
})
});