mirror of
https://github.com/cstaelen/shazarr.git
synced 2026-10-19 01:31:11 -05:00
move from eslint to oxc (#371)
This commit is contained in:
1 parent
69acae3cb8
commit
33497d932b
56 files changed
+1181
-2551
No files matched your search
@@ -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
|
||||
|
||||
@@ -105,4 +105,3 @@ jobs:
|
||||
android/app/build/outputs/apk/release/shazarr-signed.apk
|
||||
|
||||
- run: echo Build status report=${{ job.status }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
"npmClient": "pnpm",
|
||||
"webDir": "dist",
|
||||
"cordova": {}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -4,4 +4,4 @@
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "react"
|
||||
}
|
||||
}
|
||||
+39
-45
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+423
-1877
File diff suppressed because it is too large.
Load diff
+1
-8
@@ -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";
|
||||
|
||||
@@ -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 @@
|
||||
export { Analytics } from './Analytics';
|
||||
export { Analytics } from "./Analytics";
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,4 @@ type ConfigContextType = {
|
||||
};
|
||||
};
|
||||
|
||||
export const ConfigContext = createContext<ConfigContextType>(
|
||||
{} as ConfigContextType,
|
||||
);
|
||||
export const ConfigContext = createContext<ConfigContextType>({} as ConfigContextType);
|
||||
@@ -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 ;)",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -18,6 +18,4 @@ type HistoryContextType = {
|
||||
};
|
||||
};
|
||||
|
||||
export const HistoryContext = createContext<HistoryContextType>(
|
||||
{} as HistoryContextType,
|
||||
);
|
||||
export const HistoryContext = createContext<HistoryContextType>({} as HistoryContextType);
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,4 @@ type ShazarrContextType = {
|
||||
};
|
||||
};
|
||||
|
||||
export const ShazarrContext = createContext<ShazarrContextType>(
|
||||
{} as ShazarrContextType,
|
||||
);
|
||||
export const ShazarrContext = createContext<ShazarrContextType>({} as ShazarrContextType);
|
||||
@@ -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>
|
||||
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
>
|
||||
<Button onClick={onClose} variant="outlined" color="warning">
|
||||
<Refresh style={{ transform: "rotate(-180deg)" }} />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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",
|
||||
}}
|
||||
|
||||
@@ -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,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
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -16,7 +16,7 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
+3
-3
@@ -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: {},
|
||||
},
|
||||
})
|
||||
});
|
||||
Reference in new issue
Block a user