Compare commits

..

202 Commits

Author SHA1 Message Date
afkarxyz b3273b7602 v7.1.1 2026-03-11 03:19:59 +07:00
afkarxyz d495a9851c Revise README 2026-03-11 02:34:00 +07:00
afkarxyz 6f5fd1d16e Fix Typo 2026-03-10 18:50:29 +07:00
afkarxyz f4b7049f4a Update README 2026-03-10 18:45:56 +07:00
Nizar Beriane 4cccdcae77 Patch 1 (#617)
* Add SpotiFLAC CLI section to README

Added information about SpotiFLAC CLI for terminal use.

* fixed spelling mistake

* Fix typo in SpotiFLAC CLI description

---------

Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2026-03-10 18:41:24 +07:00
sh4tteredd c21d08f050 support for homebrew ffmpeg on macos (#561)
* support for homebrew ffmpeg on macos

* Add build instructions

* Revise README

---------

Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2026-03-10 18:39:01 +07:00
afkarxyz 00d3fb9212 Update README 2026-02-26 14:20:00 +07:00
afkarxyz 7b12866334 Update README 2026-02-25 17:46:44 +07:00
afkarxyz 1b415961cc Update README 2026-02-25 17:29:19 +07:00
afkarxyz 74001462b4 v7.1.0 2026-02-25 14:39:52 +07:00
Blake L. fdca1ab461 feat: Enhance ArtistInfo component with album selection and download options (#493) 2026-02-25 14:22:42 +07:00
afkarxyz 3d8ff2cedd v7.1.0 2026-02-25 14:20:48 +07:00
afkarxyz 9ef24f5a91 v7.1.0 2026-02-24 18:42:22 +07:00
afkarxyz 1314c14c59 . 2026-02-12 19:38:31 +07:00
afkarxyz cb3a6a32cb v7.0.9 2026-02-12 01:08:44 +07:00
afkarxyz df56049db2 v7.0.8 2026-02-10 21:18:05 +07:00
Yuval 36a77ad8d1 feat: Enhance GetFFmpegPath and GetFFprobePath to search the system PATH for executables. (#462) 2026-02-10 21:00:01 +07:00
diego2glez 71bce5d33e fix: add year field to lyrics and cover download template data (#453)
- Extract year from releaseDate using substring(0, 4) in both hooks
- Add year field to templateData in single download functions
- Add year field to templateData in bulk download functions
- Allows parseTemplate() to correctly replace {year} placeholder instead of defaulting to '0000'
- Fixes folder structure generation when year is used in filename or folder templates

Co-authored-by: Diego Glez <diego@example.com>
2026-02-10 20:58:41 +07:00
afkarxyz b74dec7369 .channel and community 2026-01-29 17:12:05 +07:00
afkarxyz d5c5f34d4c .telegram 2026-01-28 19:38:02 +07:00
Zarz Eleutherius 27be5c1b91 Add Telegram links to README (#402)
Added Telegram channel and community links to README.
2026-01-28 19:14:08 +07:00
afkarxyz 0c41d72ab2 v7.0.7 2026-01-27 06:34:11 +07:00
afkarxyz 25233349b9 v7.0.7 2026-01-27 06:16:05 +07:00
afkarxyz e04f6e4fdd .credits 2026-01-15 20:03:48 +07:00
afkarxyz 24bcc56a8f .credits 2026-01-15 19:58:23 +07:00
afkarxyz 45ad82bb66 .credits 2026-01-15 19:15:10 +07:00
afkarxyz 13fcb5787d Create FUNDING.yml 2026-01-15 17:57:32 +07:00
afkarxyz 556e720574 Delete .github/workflows/FUNDING.yml 2026-01-15 17:57:15 +07:00
afkarxyz 791553bdc0 .ko-fi 2026-01-15 17:56:48 +07:00
afkarxyz 9361c608ca v7.0.6 2026-01-15 16:52:50 +07:00
afkarxyz 12729e2ca1 v7.0.6 2026-01-15 15:36:19 +07:00
afkarxyz b620112886 v7.0.1 2026-01-15 15:35:20 +07:00
afkarxyz cc1c80d367 v7.0.6 2026-01-15 15:32:32 +07:00
afkarxyz 63149c91a2 v7.0.6 2026-01-15 14:27:34 +07:00
afkarxyz 1e99d8b5c6 v7.0.6 2026-01-15 13:20:04 +07:00
afkarxyz b160d3c790 v7.0.6 2026-01-15 11:03:27 +07:00
afkarxyz d9cf5a5361 .faq 2026-01-14 10:02:29 +07:00
afkarxyz 4f135f1153 v7.0.5 2026-01-14 08:23:50 +07:00
afkarxyz 4ee252f438 v7.0.5 2026-01-14 07:36:14 +07:00
afkarxyz 2fc08de757 v7.0.5 2026-01-14 06:28:51 +07:00
afkarxyz 6e3ca48d3f v7.0.5 2026-01-13 23:28:06 +07:00
afkarxyz 46a7777698 v7.0.5 2026-01-13 22:45:08 +07:00
afkarxyz 0f2174bf80 v7.0.4 2026-01-11 23:18:18 +07:00
afkarxyz 36fb34dc63 v7.0.3 2026-01-11 22:41:29 +07:00
afkarxyz 7f859db173 v7.0.2 2026-01-11 17:34:12 +07:00
afkarxyz 6e66105481 v7.0.1 2026-01-11 10:42:48 +07:00
afkarxyz 85b542983e v7.0.1 2026-01-11 10:39:37 +07:00
afkarxyz ecc6fd961a v7.0.1 2026-01-11 10:18:51 +07:00
afkarxyz 9260adc2d2 v7.0.1 2026-01-11 08:39:14 +07:00
afkarxyz cb6dfc1638 . 2026-01-11 07:52:33 +07:00
afkarxyz 5dacd70287 . 2026-01-10 12:36:51 +07:00
afkarxyz b163355c50 . 2026-01-09 22:54:57 +07:00
afkarxyz 58495dd9fd . 2026-01-09 22:50:24 +07:00
afkarxyz 1eb8a5ac0c . 2026-01-09 17:39:42 +07:00
afkarxyz 452cd9e118 . 2026-01-09 17:37:16 +07:00
Harley Welsh 1345ac25f4 fix: resolve nested download paths for covers and lyrics (#219)
This commit fixes an issue where cover art and lyrics files were being saved in deeply nested directories (e.g. Album/Artist/Album/file) instead of the correct Artist/Album/file path. It adds an isAlbum flag to the frontend hooks to prevent redundant path construction when downloading in an album context.

Co-authored-by: Harley <git@haileywelsh.me>
2026-01-08 12:34:29 +07:00
SjxSubham ae8b610462 Trim whitespace in sanitizePath function (#215)
Add whitespace trimming to sanitizePath function.
2026-01-08 12:34:08 +07:00
Rin 14297171be Security: Enforce strict validation for FFmpeg binary paths (#214) 2026-01-08 12:33:50 +07:00
afkarxyz 6f6c7563a0 .maintenance 2026-01-08 09:07:18 +07:00
afkarxyz a52c2bb658 .maintenance 2026-01-08 08:23:45 +07:00
afkarxyz 2ce400a5f7 Update README.md 2026-01-01 21:43:46 +07:00
Zarz Eleutherius b8fd2d1762 Update project name and description in README (#217) 2026-01-01 21:24:15 +07:00
afkarxyz d2af0d11df .license 2026-01-01 05:48:29 +07:00
afkarxyz 57640d85d2 v7.0 2025-12-24 09:16:25 +07:00
afkarxyz d7b0ca8b3c v7.0 2025-12-24 09:09:39 +07:00
afkarxyz 8e6a1196b5 v7.0 2025-12-24 08:55:23 +07:00
afkarxyz c150124273 v7.0 2025-12-24 08:50:43 +07:00
afkarxyz cb2a41d068 v6.9 2025-12-20 10:57:13 +07:00
afkarxyz 820a4a30ab v6.9 2025-12-20 10:43:34 +07:00
afkarxyz c9e49b4b95 v6.9 2025-12-20 10:36:39 +07:00
afkarxyz 0ba9443ef4 v6.9 2025-12-20 07:13:55 +07:00
afkarxyz 7f8c968d6a v6.9 2025-12-20 04:59:07 +07:00
afkarxyz 4fee88329b v6.9 2025-12-20 04:49:58 +07:00
afkarxyz 66c30de2db v6.9 2025-12-19 21:04:12 +07:00
afkarxyz 436feb7f7c v6.9 2025-12-19 16:50:43 +07:00
afkarxyz 7d0fde3acc v6.9 2025-12-19 13:29:28 +07:00
afkarxyz 939883c9cd v6.9 2025-12-19 13:26:19 +07:00
TheLittleDoctor 99f3d59ff1 Added a toggle to choose between using Artist property or AlbumArtist property for folder name (#169)
* Corrected function call to correctly download albums vs playlists

* Added setting to prefer AlbumArtist as folder name.
  - In practice, this prevents albums with multiple artists, featured artists, collaborations, or collections like soundtracks, from being split up
  - This is occasionally desirable behavior, so I added it as a toggle rather than a default behavior
2025-12-19 08:39:03 +07:00
Kyle Rector 965f044e0c Showing singular "song" if playlist or album only has one song (#168) 2025-12-19 08:38:50 +07:00
afkarxyz 6a3bd37eb6 Ko-fi 2025-12-17 04:57:12 +07:00
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

* Refactor DownloadFile to use default HTTP client
2025-12-13 04:09:22 +07:00
enriqueqs ffd4daf031 fixed path issue (#157)
Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2025-12-12 17:31:13 +07:00
Heggo 64b86b65a1 Fix Linux Download Path (#153)
* Fix Unknown being appended at the beginning of Download Path on Unix/Linux systems

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
afkarxyz 8f10094e40 v6.7 2025-12-08 19:33:43 +07:00
afkarxyz 2fb544d1f8 v6.6 2025-12-05 05:26:33 +07:00
afkarxyz d16eaa324a v6.6 2025-12-05 05:25:50 +07:00
afkarxyz cc3f7640c6 v6.5 2025-11-30 05:38:44 +07:00
Lukas 2653586eea Download Queue & Progress UI (#123)
* Add download queue tracking and UI integration

Introduces backend support for a download queue with item tracking, status updates, and session statistics. Adds frontend components and hooks for displaying and managing the download queue, including a dialog and toast indicator. Updates download logic to pre-add items to the queue, track progress, and handle completion, skipping, and failure states. Integrates @radix-ui/react-scroll-area for improved UI scrolling.

* Add session stats to DownloadQueue dialog

Introduces session statistics (downloaded amount, speed, and duration) to the DownloadQueue dialog for improved user feedback. Also adjusts dialog sizing for better display and removes the sm:max-w-lg restriction in dialog.tsx for more flexible width.
2025-11-29 17:36:58 +07:00
afkarxyz 0c92385c56 v6.4 2025-11-27 14:14:19 +07:00
afkarxyz 957fb83dbc v6.4 2025-11-27 06:49:33 +07:00
afkarxyz 90f1871488 UPX 2025-11-27 04:58:53 +07:00
afkarxyz 2d0e5055f8 UPX 2025-11-27 04:52:16 +07:00
afkarxyz 08d02be4f2 v6.3 2025-11-26 10:55:08 +07:00
afkarxyz 5b542dcf29 v6.3 2025-11-26 10:48:26 +07:00
afkarxyz 48f9584027 v6.3 2025-11-26 10:47:02 +07:00
afkarxyz 4241a591aa Fallback Tidal with search and ISRC matching 2025-11-26 07:32:26 +07:00
afkarxyz f346fbb6ba Refactor README.md for badge updates and formatting 2025-11-26 04:51:34 +07:00
Lukas 2172981110 Add FLAC audio quality analysis and spectrum visualization (#110)
Introduces backend support for analyzing FLAC audio files, including technical metrics and frequency spectrum extraction. Adds frontend components and hooks for file selection, analysis, and visualization, integrating a new Audio Quality Analyzer dialog into the UI. Updates types and dependencies to support audio analysis features.
2025-11-26 04:05:12 +07:00
afkarxyz ddf1844237 v6.2 2025-11-25 13:25:29 +07:00
afkarxyz 3a5aea4c91 v6.2 2025-11-25 13:19:11 +07:00
afkarxyz 6b4ad16882 v6.2 2025-11-25 13:15:43 +07:00
afkarxyz 4dbd88e689 v6.1 2025-11-24 15:19:25 +07:00
afkarxyz d0665fdcc5 v6.1 2025-11-24 15:15:35 +07:00
afkarxyz 6ee3c2f653 v6.1 2025-11-24 14:52:47 +07:00
afkarxyz 73d8205f6f v6.0 2025-11-24 05:29:06 +07:00
afkarxyz 17fe37fbb7 v6.0 2025-11-24 05:22:04 +07:00
afkarxyz afe55db107 v6.0 2025-11-24 05:19:25 +07:00
afkarxyz 8a553774c6 v6.0 2025-11-24 05:15:24 +07:00
afkarxyz 869bf50330 SpotiDownloader 2025-11-24 03:35:26 +07:00
afkarxyz 1c03aaa92f v5.9 2025-11-23 10:40:27 +07:00
afkarxyz 1423a32528 v5.9 2025-11-23 10:32:53 +07:00
afkarxyz 633812faab v5.8 2025-11-23 05:45:30 +07:00
afkarxyz 355b68c8de v5.8 2025-11-23 05:36:11 +07:00
afkarxyz 884716069c v5.7-patch1-build4 2025-11-23 05:24:26 +07:00
afkarxyz 44658f6ba6 v5.7-patch1-build3 2025-11-23 05:11:51 +07:00
afkarxyz f9974d4a3e v5.7-patch1-build2 2025-11-23 05:09:02 +07:00
afkarxyz 840f26dd6f v5.7-patch1-build1 2025-11-23 05:05:15 +07:00
afkarxyz 5831a45839 v5.7-patch1 2025-11-23 04:58:45 +07:00
afkarxyz d1bd7da2de Update screenshot in README.md 2025-11-23 04:48:12 +07:00
afkarxyz 033980bbd2 Add 'jakarta.monochrome.tf' to tidal.json 2025-11-23 02:35:11 +07:00
afkarxyz 3ae039d7db v5.7 2025-11-22 18:08:34 +07:00
afkarxyz 436a98c606 Replace screenshot in README.md
Updated screenshot in the README file.
2025-11-22 18:06:24 +07:00
afkarxyz d90221b835 v5.7 2025-11-22 17:59:48 +07:00
afkarxyz 8a2dbe4e32 v5.7-beta3 2025-11-22 15:45:17 +07:00
afkarxyz ee2976143a v5.7-beta2 2025-11-22 12:06:00 +07:00
afkarxyz 1c9bba0140 Update screenshot in README.md 2025-11-22 12:03:50 +07:00
afkarxyz 10236f00c6 v5.7-beta1 2025-11-22 11:46:36 +07:00
Ahmed Alghafri a49bb560bd Add cross-platform path handling (#89)
Add cross-platform path handling support

- Add sanitizePath, joinPath, buildOutputPath utilities
- Add operatingSystem to Settings interface
- Replace hardcoded Windows paths with dynamic path handling
- Support Windows, Linux, and macOS
2025-11-22 10:36:25 +07:00
afkarxyz bb9e2dcbb6 v5.6 2025-11-22 07:42:41 +07:00
afkarxyz 7aeefc1fe5 v5.6
Added a download link to the README.
2025-11-22 07:42:21 +07:00
afkarxyz 78afdbf77f Update build.yml 2025-11-22 07:31:25 +07:00
afkarxyz 68e2699941 Update build.yml 2025-11-22 07:25:57 +07:00
afkarxyz cb6515898a Update build.yml 2025-11-22 07:05:35 +07:00
afkarxyz 5fcd1f2f75 Update build.yml 2025-11-22 06:59:23 +07:00
afkarxyz ba732f03b0 Update build.yml 2025-11-22 06:55:21 +07:00
afkarxyz 427fd33a41 Update build.yml 2025-11-22 06:50:20 +07:00
afkarxyz b4204a3343 Update build.yml 2025-11-22 06:30:52 +07:00
afkarxyz 9107f9a5fd Update README with new images and download links 2025-11-22 06:29:14 +07:00
afkarxyz 0c284ba62c v5.6 2025-11-22 06:16:18 +07:00
afkarxyz 50ca20ce0f v5.5 2025-11-21 23:36:49 +07:00
afkarxyz 80888ca5ad v5.5 2025-11-21 23:32:51 +07:00
afkarxyz b8811d9881 Update README.md 2025-11-21 23:30:49 +07:00
afkarxyz a7bcc0249c Update tidal.json 2025-11-21 02:41:06 +07:00
afkarxyz 735c7cb117 Replace image link in README.md 2025-11-17 13:03:02 +07:00
afkarxyz 05c0f50243 v5.4 2025-11-17 12:52:12 +07:00
afkarxyz 711c5a98d3 v5.4 2025-11-17 12:48:04 +07:00
afkarxyz fd949a17f0 Add tidal.json with a list of sites 2025-11-17 11:49:38 +07:00
afkarxyz 1c0de8b3ac Merge pull request #69 from MaitreGEEK/patch-1
Update download link to latest release
2025-10-22 13:48:50 +07:00
MaîtreGEEK b653a8ca41 Update download link to latest release
In that way when you release a new version you don't have to modify the README
2025-10-22 08:46:20 +02:00
afkarxyz 2d0c174c50 v5.3 2025-10-22 05:17:57 +07:00
afkarxyz c63eeccc55 v5.3 2025-10-22 05:17:22 +07:00
afkarxyz a620c16b1c v5.3 2025-10-22 05:13:44 +07:00
afkarxyz cf27ae098d v5.2 2025-10-21 03:26:31 +07:00
afkarxyz a0c60a473a . 2025-10-21 03:26:01 +07:00
afkarxyz 25c5a4d175 v5.2 2025-10-21 03:23:28 +07:00
afkarxyz 33a6137f75 v5.1 2025-10-21 02:16:31 +07:00
afkarxyz b4fcb6bca6 v5.1 2025-10-21 02:11:41 +07:00
afkarxyz 5ab19a6d37 . 2025-10-21 02:10:50 +07:00
afkarxyz 8547e6d410 v5.0 2025-10-13 05:37:10 +07:00
afkarxyz 17666d8027 Update version.json 2025-10-13 05:16:51 +07:00
afkarxyz ab208482ca v5.0 2025-10-13 05:09:53 +07:00
afkarxyz 76e02d77e8 v5.0 2025-10-13 05:05:01 +07:00
afkarxyz 75cc4543ad Merge pull request #65 from petacz/patch-1
Use Embedded ISRC Tags to check for existing files
2025-10-13 04:55:34 +07:00
Petr V 0b468c4b60 Use Embedded ISRC Tags to check for existing files 2025-10-12 19:53:28 +02:00
afkarxyz 87a6a778f7 v4.9 2025-10-12 00:30:15 +07:00
afkarxyz ef893ab9f4 v4.9 2025-10-12 00:26:01 +07:00
afkarxyz 3eda3245ca v4.9 2025-10-12 00:23:26 +07:00
afkarxyz f6f238361c v4.9 2025-10-12 00:22:30 +07:00
afkarxyz 998730bbb3 v4.8 2025-10-11 18:18:27 +07:00
afkarxyz 56a1d29d78 v4.8 2025-10-11 18:17:07 +07:00
afkarxyz 4b7316636e v4.7 2025-10-05 04:03:54 +07:00
afkarxyz 55669ec45f v4.7 2025-10-05 03:59:50 +07:00
afkarxyz 3304b13828 Update README.md 2025-10-05 03:42:30 +07:00
afkarxyz 579bb1415a Merge pull request #61 from value1338/main
Add button to auto-delete already downloaded files and retry failed ones
2025-10-05 03:40:21 +07:00
afkarxyz f0e71261a5 Update version.json 2025-10-05 03:39:40 +07:00
value1338 e2ad51da34 included Skipped Songs for Removal from Playlist 2025-10-04 13:06:13 +02:00
value1338 9e403ab1ba deleted comments in the code 2025-10-03 20:21:12 +02:00
value1338 7058559ddc Add button to auto-delete already downloaded files and retry failed ones 2025-10-03 19:47:51 +02:00
afkarxyz 861f303a4f v4.6 2025-09-20 08:34:27 +07:00
afkarxyz 9a28e8bd94 v4.6 2025-09-20 08:32:12 +07:00
afkarxyz f75385c4e8 open.spotify.com/intl-pt 2025-09-16 15:37:54 +07:00
afkarxyz 2eac274ee0 v4.5 2025-09-11 13:28:49 +07:00
afkarxyz 49a8de1b35 v4.5 2025-09-11 13:26:52 +07:00
afkarxyz cd2500d1df Update README.md 2025-09-11 13:24:17 +07:00
afkarxyz ea1372f1fe v4.4 2025-08-08 00:20:44 +07:00
afkarxyz 65fbb9a8e9 v4.4 2025-08-08 00:20:57 +07:00
afkarxyz de16d9e25d v4.4 2025-08-08 00:17:03 +07:00
afkarxyz 6dd19b563b v4.3 2025-08-06 20:24:58 +07:00
afkarxyz 303b76d1ec v4.3 2025-08-06 20:21:52 +07:00
afkarxyz dbcd49225d v4.2 2025-07-26 09:40:32 +07:00
155 changed files with 30638 additions and 3288 deletions
+2
View File
@@ -0,0 +1,2 @@
github: afkarxyz
ko_fi: afkarxyz
+393
View File
@@ -0,0 +1,393 @@
name: Build Multi-Platform
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
GO_VERSION: '1.26'
NODE_VERSION: '24'
jobs:
build-windows:
name: Build Windows
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
working-directory: frontend
run: |
pnpm install
pnpm run generate-icon
- name: Install UPX
run: |
choco install upx -y
- name: Build application
run: wails build -platform windows/amd64
- name: Compress with UPX
run: |
upx --best --lzma "build\bin\SpotiFLAC.exe"
- name: Prepare artifacts
run: |
mkdir -p dist
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-portable
path: dist/SpotiFLAC.exe
retention-days: 7
build-macos:
name: Build macOS
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
working-directory: frontend
run: |
pnpm install
pnpm run generate-icon
- name: Build application
run: wails build -platform darwin/universal
- name: Create DMG
run: |
mkdir -p dist
# Install create-dmg if not available
brew install create-dmg || true
# Create DMG
create-dmg \
--volname "SpotiFLAC" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "SpotiFLAC.app" 175 120 \
--hide-extension "SpotiFLAC.app" \
--app-drop-link 425 120 \
"dist/SpotiFLAC.dmg" \
"build/bin/SpotiFLAC.app" || \
# Fallback to hdiutil if create-dmg fails
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-portable
path: dist/SpotiFLAC.dmg
retention-days: 7
build-linux:
name: Build Linux
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
working-directory: frontend
run: |
pnpm install
pnpm run generate-icon
- name: Build application
run: wails build -platform linux/amd64
- name: Compress with UPX
run: |
upx --best --lzma build/bin/SpotiFLAC
- name: Cache appimagetool
id: cache-appimagetool
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-x86_64-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
- name: Create AppImage
run: |
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
# Copy binary
cp build/bin/SpotiFLAC AppDir/usr/bin/
# Create desktop file
cat > AppDir/spotiflac.desktop << 'EOF'
[Desktop Entry]
Name=SpotiFLAC
Exec=SpotiFLAC
Icon=spotiflac
Type=Application
Categories=Audio;AudioVideo;
Comment=Get Spotify tracks in true FLAC from Tidal/Deezer
EOF
cp AppDir/spotiflac.desktop AppDir/usr/share/applications/
# Create icon
if [ -f "build/appicon.png" ]; then
convert build/appicon.png -resize 256x256 AppDir/spotiflac.png
elif [ -f "frontend/public/icon.svg" ]; then
convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png
else
echo "Warning: No icon found, building without icon"
fi
# Copy icon if exists
if [ -f "AppDir/spotiflac.png" ]; then
cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/
cp AppDir/spotiflac.png AppDir/.DirIcon
fi
# Create AppRun
cat > AppDir/AppRun << 'EOF'
#!/bin/sh
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
export PATH="${HERE}/usr/bin/:${PATH}"
export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}"
exec "${HERE}/usr/bin/SpotiFLAC" "$@"
EOF
chmod +x AppDir/AppRun
# Create AppImage
mkdir -p dist
ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-portable
path: dist/SpotiFLAC.AppImage
retention-days: 7
create-release:
name: Create Release
needs: [build-windows, build-macos, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: true
prerelease: false
generate_release_notes: false
body: |
## Changelog
## Downloads
- `SpotiFLAC.exe` - Windows
- `SpotiFLAC.dmg` - macOS
- `SpotiFLAC.AppImage` - Linux
<details>
<summary><b>Linux Requirements</b></summary>
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
**Ubuntu/Debian:**
```bash
sudo apt install libwebkit2gtk-4.1-0
```
**Arch Linux:**
```bash
sudo pacman -S webkit2gtk-4.1
```
**Fedora:**
```bash
sudo dnf install webkit2gtk4.1
```
After installing the dependency, make the AppImage executable:
```bash
chmod +x SpotiFLAC.AppImage
./SpotiFLAC.AppImage
```
</details>
files: |
artifacts/windows-portable/*.exe
artifacts/macos-portable/*.dmg
artifacts/linux-portable/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+64
View File
@@ -0,0 +1,64 @@
# Wails Build
build/
*.exe
*.dll
*.dylib
*.so
# Wails Generated Files
frontend/wailsjs/
# Go
*.test
*.out
go.work
go.work.sum
# Node / Frontend
node_modules/
frontend/node_modules/
frontend/dist/
frontend/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.npm
.yarn
*.tsbuildinfo
# IDE / Editors
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# OS
Thumbs.db
desktop.ini
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Temporary files
tmp/
temp/
*.tmp
*.bak
*.old
# Test files
test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
push.bat
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 afkarxyz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+86 -14
View File
@@ -1,25 +1,97 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
# SpotiFLAC
![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06)
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<div align="center">
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
</div>
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.1/SpotiFLAC.exe)
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
## Screenshots
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
![image](https://github.com/user-attachments/assets/180b8322-ce2d-4842-a5dd-ac4d7b7a5efa)
![Image](https://github.com/user-attachments/assets/c2624ca5-8569-49f0-950e-4410b523cea1)
![image](https://github.com/user-attachments/assets/3f84d53b-2da1-4488-986c-772b82832f2d)
## Other projects
![image](https://github.com/user-attachments/assets/f604dc04-4ee6-4084-b314-0be7cd5d7ef9)
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
## Lossless Audio Check
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker
### [SpotubeDL](https://spotubedl.com)
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
## FAQ
### Is this software free?
_Yes. This software is completely free.
You do not need an account, login, or subscription.
All you need is an internet connection._
### Can using this software get my Spotify account suspended or banned?
_No.
This software has no connection to your Spotify account.
Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication._
### Where does the audio come from?
_The audio is fetched using third-party APIs._
### Why does metadata fetching sometimes fail?
_This usually happens because your IP address has been rate-limited.
You can wait and try again later, or use a VPN to bypass the rate limit._
### Why does Windows Defender or antivirus flag or delete the file?
_This is a false positive.
It likely happens because the executable is compressed using UPX._
_If you are concerned, you can fork the repository and build the software yourself from source._
### Want to support the project?
_If this software is useful and brings you value,
consider supporting the project by buying me a coffee.
Your support helps keep development going._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music or any other streaming service.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
-1792
View File
File diff suppressed because it is too large Load Diff
+1461
View File
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
type AmazonDownloader struct {
client *http.Client
regions []string
}
type SongLinkResponse struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
Timeout: 120 * time.Second,
},
regions: []string{"us", "eu"},
}
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase := "https://open.spotify.com/track/"
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
apiBase := "https://api.song.link/v1-alpha.1/links?url="
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
fmt.Println("Getting Amazon URL...")
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
var songLinkResp SongLinkResponse
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
if !ok || amazonLink.URL == "" {
return "", fmt.Errorf("amazon Music link not found")
}
amazonURL := amazonLink.URL
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
trackAsin := strings.Split(parts[1], "&")[0]
amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
}
}
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
return amazonURL, nil
}
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
asin := asinRegex.FindString(amazonURL)
if asin == "" {
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if apiResp.StreamURL == "" {
return "", fmt.Errorf("no stream URL found in response")
}
downloadURL := apiResp.StreamURL
fileName := fmt.Sprintf("%s.m4a", asin)
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
dlResp, err := a.client.Do(dlReq)
if err != nil {
return "", err
}
defer dlResp.Body.Close()
fmt.Printf("Downloading track: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", err
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
if apiResp.DecryptionKey != "" {
fmt.Printf("Decrypting file...\n")
ffprobePath, err := GetFFprobePath()
var codec string
if err == nil {
cmdProbe := exec.Command(ffprobePath,
"-v", "quiet",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
)
setHideWindow(cmdProbe)
codecOutput, _ := cmdProbe.Output()
codec = strings.TrimSpace(string(codecOutput))
fmt.Printf("Detected codec: %s\n", codec)
}
targetExt := ".m4a"
if codec == "flac" {
targetExt = ".flac"
}
decryptedFilename := "dec_" + fileName + targetExt
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
}
decryptedPath := filepath.Join(outputDir, decryptedFilename)
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
}
key := strings.TrimSpace(apiResp.DecryptionKey)
cmd := exec.Command(ffmpegPath,
"-decryption_key", key,
"-i", filePath,
"-c", "copy",
"-y",
decryptedPath,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
outStr := string(output)
if len(outStr) > 500 {
outStr = outStr[len(outStr)-500:]
}
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
}
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("decrypted file missing or empty")
}
if err := os.Remove(filePath); err != nil {
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
}
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
if err := os.Rename(decryptedPath, finalPath); err != nil {
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
}
filePath = finalPath
fmt.Println("Decryption successful")
}
return filePath, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
if spotifyTrackName != "" && spotifyArtistName != "" {
filenameArtist := spotifyArtistName
filenameAlbumArtist := spotifyAlbumArtist
if useFirstArtistOnly {
filenameArtist = GetFirstArtist(spotifyArtistName)
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
}
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + expectedPath, nil
}
}
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
if err != nil {
return "", err
}
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
originalFileDir := filepath.Dir(filePath)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
var newFilename string
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
}
ext := filepath.Ext(filePath)
if ext == "" {
ext = ".flac"
}
newFilename = newFilename + ext
newFilePath := filepath.Join(outputDir, newFilename)
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
filePath = newFilePath
fmt.Printf("Renamed to: %s\n", newFilename)
}
}
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
if _, err := os.Stat(originalM4aPath); err == nil {
if err := os.Remove(originalM4aPath); err != nil {
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
} else {
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
}
}
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string,
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
}
+278
View File
@@ -0,0 +1,278 @@
package backend
import (
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac"
)
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
TotalSamples uint64 `json:"total_samples"`
Duration float64 `json:"duration"`
Bitrate int `json:"bit_rate"`
BitDepth string `json:"bit_depth"`
DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"`
RMSLevel float64 `json:"rms_level"`
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.Channels = ((data[12] >> 1) & 0x07) + 1
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
}
}
}
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
calculateRealAudioMetrics(result, filepath)
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
var peak float64
var sumSquares float64
for _, sample := range samples {
absVal := sample
if absVal < 0 {
absVal = -absVal
}
if absVal > peak {
peak = absVal
}
sumSquares += sample * sample
}
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
result.DynamicRange = peakDB - rmsDB
}
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
return nil, err
}
defer stream.Close()
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
return samples, nil
}
normalized := float64(sample) / maxVal
samples = append(samples, normalized)
}
if len(samples) >= maxSamples {
break
}
}
return samples, nil
}
func GetFileSize(filepath string) (int64, error) {
info, err := os.Stat(filepath)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
return GetMetadataWithFFprobe(filepath)
}
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
for i := 0; i < 5; i++ {
if f, err := os.Open(filePath); err == nil {
f.Close()
break
}
time.Sleep(200 * time.Millisecond)
}
args := []string{
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
}
cmd := exec.Command(ffprobePath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) < 4 {
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
}
res := &AnalysisResult{
FilePath: filePath,
}
if info, err := os.Stat(filePath); err == nil {
res.FileSize = info.Size()
}
infoMap := make(map[string]string)
args = []string{
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=0",
filePath,
}
cmd = exec.Command(ffprobePath, args...)
setHideWindow(cmd)
output, err = cmd.CombinedOutput()
if err == nil {
lines = strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
if val, ok := infoMap["sample_rate"]; ok {
s, _ := strconv.Atoi(val)
res.SampleRate = uint32(s)
}
if val, ok := infoMap["channels"]; ok {
c, _ := strconv.Atoi(val)
res.Channels = uint8(c)
}
if val, ok := infoMap["duration"]; ok {
d, _ := strconv.ParseFloat(val, 64)
res.Duration = d
}
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
br, _ := strconv.Atoi(val)
res.Bitrate = br
}
bits := 0
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
bits, _ = strconv.Atoi(val)
}
if bits == 0 {
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
bits, _ = strconv.Atoi(val)
}
}
res.BitsPerSample = uint8(bits)
if bits > 0 {
res.BitDepth = fmt.Sprintf("%d-bit", bits)
} else {
res.BitDepth = "Unknown"
}
return res, nil
}
+17
View File
@@ -0,0 +1,17 @@
package backend
import (
"os"
"path/filepath"
)
func GetDefaultMusicPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return "C:\\Users\\Public\\Music"
}
return filepath.Join(homeDir, "Music")
}
+526
View File
@@ -0,0 +1,526 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
DiscNumber int `json:"disc_number"`
}
type CoverDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type HeaderDownloadRequest struct {
HeaderURL string `json:"header_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type HeaderDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type CoverClient struct {
httpClient *http.Client
}
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title-artist":
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
case "title":
filename = safeTitle
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d - %s", position, filename)
}
}
return filename + ".jpg"
}
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
mediumURL := convertSmallToMedium(imageURL)
if strings.Contains(mediumURL, spotifySize640) {
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
}
return mediumURL
}
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
downloadURL := convertSmallToMedium(coverURL)
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(downloadURL)
}
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
}
return nil
}
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
Success: false,
Error: "Cover URL is required",
}, fmt.Errorf("cover URL is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist"
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
Message: "Cover file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
downloadURL := c.getMaxResolutionURL(req.CoverURL)
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write cover file: %v", err),
}, err
}
return &CoverDownloadResponse{
Success: true,
Message: "Cover downloaded successfully",
File: filePath,
}, nil
}
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
if req.HeaderURL == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Header URL is required",
}, fmt.Errorf("header URL is required")
}
if req.ArtistName == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &HeaderDownloadResponse{
Success: true,
Message: "Header file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.HeaderURL)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write header file: %v", err),
}, err
}
return &HeaderDownloadResponse{
Success: true,
Message: "Header downloaded successfully",
File: filePath,
}, nil
}
type GalleryImageDownloadRequest struct {
ImageURL string `json:"image_url"`
ArtistName string `json:"artist_name"`
ImageIndex int `json:"image_index"`
OutputDir string `json:"output_dir"`
}
type GalleryImageDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
if req.ImageURL == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Image URL is required",
}, fmt.Errorf("image URL is required")
}
if req.ArtistName == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.ImageURL)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
}, err
}
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image downloaded successfully",
File: filePath,
}, nil
}
type AvatarDownloadRequest struct {
AvatarURL string `json:"avatar_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type AvatarDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
if req.AvatarURL == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Avatar URL is required",
}, fmt.Errorf("avatar URL is required")
}
if req.ArtistName == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.AvatarURL)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write avatar file: %v", err),
}, err
}
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar downloaded successfully",
File: filePath,
}, nil
}
+703
View File
@@ -0,0 +1,703 @@
package backend
import (
"archive/tar"
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/ulikunitz/xz"
)
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if !filepath.IsAbs(cleanedPath) {
return fmt.Errorf("path must be absolute: %s", path)
}
info, err := os.Stat(cleanedPath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory: %s", path)
}
if runtime.GOOS != "windows" {
if info.Mode()&0111 == 0 {
return fmt.Errorf("file is not executable: %s", path)
}
}
base := filepath.Base(cleanedPath)
validNames := map[string]bool{
"ffmpeg": true,
"ffmpeg.exe": true,
"ffprobe": true,
"ffprobe.exe": true,
}
if !validNames[base] {
return fmt.Errorf("invalid executable name: %s", base)
}
return nil
}
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".spotiflac"), nil
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
path, err := exec.LookPath(ffmpegName)
if err == nil {
return path, nil
}
return localPath, nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
path, err := exec.LookPath(ffprobeName)
if err == nil {
return path, nil
}
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
const (
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
)
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return err
}
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
isARM := runtime.GOARCH == "arm64"
var macFFmpegURLs []string
var macFFprobeURLs []string
if isARM {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
} else {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
}
if !ffmpegInstalled && !ffprobeInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
return err
}
} else if !ffmpegInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
}
return nil
}
var url string
switch runtime.GOOS {
case "windows":
url = ffmpegWindowsURL
case "linux":
url = ffmpegLinuxURL
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
return nil
}
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
var lastErr error
for _, url := range urls {
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
err := downloadAndExtract(url, destDir, progressCallback, start, end)
if err == nil {
return nil
}
lastErr = err
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
}
return fmt.Errorf("all download attempts failed: %w", lastErr)
}
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
}
totalSize := resp.ContentLength
var downloaded int64
lastTime := time.Now()
var lastBytes int64
if totalSize > 0 {
totalSizeMB := float64(totalSize) / (1024 * 1024)
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
} else {
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
}
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := tmpFile.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
mbDownloaded := float64(downloaded) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 {
bytesDiff := float64(downloaded - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
lastTime = now
lastBytes = downloaded
}
SetDownloadProgress(mbDownloaded)
if speedMBps > 0 {
SetDownloadSpeed(speedMBps)
}
if totalSize > 0 && progressCallback != nil {
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
if totalSize > 0 {
percent := float64(downloaded) * 100 / float64(totalSize)
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
mbDownloaded, float64(totalSize)/(1024*1024), percent)
}
} else {
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
}
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
}
tmpFile.Close()
if totalSize > 0 {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
} else {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
}
fmt.Printf("[FFmpeg] Extracting...\n")
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
}
foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File {
baseName := filepath.Base(f.Name)
if f.FileInfo().IsDir() {
continue
}
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file in zip: %w", err)
}
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
return fmt.Errorf("failed to open tar.xz: %w", err)
}
defer file.Close()
xzReader, err := xz.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create xz reader: %w", err)
}
tarReader := tar.NewReader(xzReader)
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
baseName := filepath.Base(header.Name)
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
Codec string `json:"codec"`
}
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
}
installed, err := IsFFmpegInstalled()
if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed")
}
results := make([]ConvertAudioResult, len(req.InputFiles))
var wg sync.WaitGroup
var mu sync.Mutex
for i, inputFile := range req.InputFiles {
wg.Add(1)
go func(idx int, inputFile string) {
defer wg.Done()
result := ConvertAudioResult{
InputFile: inputFile,
}
inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile)
outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper)
if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt)
if inputExt == outputExt {
result.Error = "Input and output formats are the same"
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
result.OutputFile = outputFile
var coverArtPath string
var lyrics string
var inputMetadata Metadata
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
}
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
} else if lyrics != "" {
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
} else {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
inputMetadata.Lyrics = lyrics
args := []string{
"-i", inputFile,
"-y",
}
switch req.OutputFormat {
case "mp3":
args = append(args,
"-codec:a", "libmp3lame",
"-b:a", req.Bitrate,
"-map", "0:a",
"-id3v2_version", "3",
)
case "m4a":
codec := req.Codec
if codec == "" {
codec = "aac"
}
if codec == "alac" {
args = append(args,
"-codec:a", "alac",
"-map", "0:a",
)
} else {
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a",
)
}
}
args = append(args, outputFile)
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
if coverArtPath != "" {
os.Remove(coverArtPath)
}
return
}
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
}
if lyrics != "" {
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
}
}
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, inputFile)
}
wg.Wait()
return results, nil
}
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
Format string `json:"format"`
Size int64 `json:"size"`
}
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
return &AudioFileInfo{
Path: filePath,
Filename: filepath.Base(filePath),
Format: ext,
Size: info.Size(),
}, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
func setHideWindow(cmd *exec.Cmd) {
}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
// +build windows
package backend
import (
"os/exec"
"syscall"
)
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
+49
View File
@@ -0,0 +1,49 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
},
{
DisplayName: "MP3 Files (*.mp3)",
Pattern: "*.mp3",
},
{
DisplayName: "M4A Files (*.m4a)",
Pattern: "*.m4a",
},
{
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return nil, err
}
return files, nil
}
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
})
if err != nil {
return "", err
}
return dir, nil
}
+468
View File
@@ -0,0 +1,468 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Children []FileInfo `json:"children,omitempty"`
}
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
Year string `json:"year"`
}
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
NewName string `json:"new_name"`
NewPath string `json:"new_path"`
Error string `json:"error,omitempty"`
Metadata AudioMetadata `json:"metadata"`
}
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var result []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
fileInfo := FileInfo{
Name: entry.Name(),
Path: filepath.Join(dirPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
}
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
fileInfo.Children = children
}
}
result = append(result, fileInfo)
}
return result, nil
}
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
IsDir: false,
Size: info.Size(),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return result, nil
}
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return readFlacMetadata(filePath)
case ".mp3":
return readMp3Metadata(filePath)
case ".m4a":
return readM4aMetadata(filePath)
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
}
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
metadata := &AudioMetadata{}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
value := parts[1]
switch fieldName {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST":
metadata.AlbumArtist = value
case "TRACKNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.TrackNumber = num
}
case "DISCNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.DiscNumber = num
}
case "DATE", "YEAR":
metadata.Year = value
}
}
}
}
return metadata, nil
}
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
metadata := &AudioMetadata{
Title: tag.Title(),
Artist: tag.Artist(),
Album: tag.Album(),
Year: tag.Year(),
}
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
}
}
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
}
}
return metadata, nil
}
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return nil, err
}
metadata := &AudioMetadata{}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
case "disc":
discStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
case "date", "year":
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
}
}
return metadata, nil
}
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
return &AudioMetadata{}, nil
}
return metadata, nil
}
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
}
result := format
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
result = strings.Trim(result, " -._")
if result == "" {
return ""
}
return result + ext
}
func sanitizeFilenameForRename(name string) string {
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
result = strings.ReplaceAll(result, char, "")
}
return strings.TrimSpace(result)
}
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
for _, filePath := range files {
preview := RenamePreview{
OldPath: filePath,
OldName: filepath.Base(filePath),
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
preview.Error = err.Error()
previews = append(previews, preview)
continue
}
preview.Metadata = *metadata
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
preview.Error = "Could not generate filename (missing metadata)"
previews = append(previews, preview)
continue
}
preview.NewName = newName
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
previews = append(previews, preview)
}
return previews
}
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
info, err := os.Stat(filePath)
if err == nil {
result[filePath] = info.Size()
}
}
return result
}
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
for _, filePath := range files {
result := RenameResult{
OldPath: filePath,
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
result.Error = "Could not generate filename (missing metadata)"
result.Success = false
results = append(results, result)
continue
}
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
result.Success = false
results = append(results, result)
continue
}
}
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
result.Success = true
results = append(results, result)
}
return results
}
+200
View File
@@ -0,0 +1,200 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
safeTitle := SanitizeFilename(trackName)
safeArtist := SanitizeFilename(artistName)
safeAlbum := SanitizeFilename(albumName)
safeAlbumArtist := SanitizeFilename(albumArtist)
safePlaylist := SanitizeFilename(playlistName)
safeCreator := SanitizeFilename(playlistOwner)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".flac"
}
func SanitizeFilename(name string) string {
sanitized := strings.ReplaceAll(name, "/", " ")
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
var result strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
func GetFirstArtist(artistString string) string {
if artistString == "" {
return ""
}
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
for _, d := range delimiters {
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
return strings.TrimSpace(artistString[:idx])
}
}
return artistString
}
func NormalizePath(folderPath string) string {
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
func GetSeparator() string {
dir, err := GetFFmpegDir()
if err != nil {
return "; "
}
configPath := filepath.Join(dir, "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
return "; "
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err == nil {
if sep, ok := settings["separator"].(string); ok {
if sep == "comma" {
return ", "
}
if sep == "semicolon" {
return "; "
}
}
}
return "; "
}
func SanitizeFolderPath(folderPath string) string {
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
sep := string(filepath.Separator)
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
}
}
return strings.Join(sanitizedParts, sep)
}
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
func sanitizeFilename(name string) string {
return SanitizeFilename(name)
}
+99
View File
@@ -0,0 +1,99 @@
package backend
import (
"context"
"os/exec"
"runtime"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func OpenFolderInExplorer(path string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin":
cmd = exec.Command("open", path)
case "linux":
cmd = exec.Command("xdg-open", path)
default:
cmd = exec.Command("xdg-open", path)
}
return cmd.Start()
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
options := wailsRuntime.OpenDialogOptions{
Title: "Select Download Folder",
DefaultDirectory: defaultPath,
}
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
if err != nil {
return "", err
}
if selectedPath == "" {
return "", nil
}
return selectedPath, nil
}
func SelectFileDialog(ctx context.Context) (string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select FLAC File for Analysis",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "FLAC Audio Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
if err != nil {
return "", err
}
if selectedFile == "" {
return "", nil
}
return selectedFile, nil
}
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select Image or Video",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
if err != nil {
return nil, err
}
return selectedPaths, nil
}
+326
View File
@@ -0,0 +1,326 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
type HistoryItem struct {
ID string `json:"id"`
SpotifyID string `json:"spotify_id"`
Title string `json:"title"`
Artists string `json:"artists"`
Album string `json:"album"`
DurationStr string `json:"duration_str"`
CoverURL string `json:"cover_url"`
Quality string `json:"quality"`
Format string `json:"format"`
Path string `json:"path"`
Timestamp int64 `json:"timestamp"`
}
var historyDB *bolt.DB
const (
historyBucket = "DownloadHistory"
maxHistory = 10000
)
func InitHistoryDB(appName string) error {
appDir, err := GetFFmpegDir()
if err != nil {
return err
}
if _, err := os.Stat(appDir); os.IsNotExist(err) {
os.MkdirAll(appDir, 0755)
}
dbPath := filepath.Join(appDir, "history.db")
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
return err
})
if err != nil {
db.Close()
return err
}
historyDB = db
return nil
}
func CloseHistoryDB() {
if historyDB != nil {
historyDB.Close()
}
}
func AddHistoryItem(item HistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetHistoryItems(appName string) ([]HistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []HistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item HistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(historyBucket))
})
}
type FetchHistoryItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
const (
fetchHistoryBucket = "FetchHistory"
)
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
if item.URL != "" {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var existing FetchHistoryItem
if err := json.Unmarshal(v, &existing); err == nil {
if existing.URL == item.URL && existing.Type == item.Type {
if err := b.Delete(k); err != nil {
return err
}
}
}
}
}
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []FetchHistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearFetchHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(fetchHistoryBucket))
})
}
func ClearFetchHistoryByType(itemType string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
var keysToDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
if item.Type == itemType {
keysToDelete = append(keysToDelete, k)
}
}
}
for _, k := range keysToDelete {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
func DeleteHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
func DeleteFetchHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
+532
View File
@@ -0,0 +1,532 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
}
type LyricsDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type LyricsClient struct {
httpClient *http.Client
}
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
url.QueryEscape(artistName),
url.QueryEscape(trackName))
if albumName != "" {
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
}
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
}
var lrcLibResp LRCLibResponse
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
}
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
SyncType: "LINE_SYNCED",
Lines: []LyricsLine{},
}
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
resp.SyncType = "UNSYNCED"
}
if lyricsText == "" {
resp.Error = true
return resp
}
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: words,
})
continue
}
}
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "",
Words: line,
})
}
return resp
}
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return 0
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
url.QueryEscape(artistName),
url.QueryEscape(trackName))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %v", err)
}
var results []LRCLibResponse
if err := json.Unmarshal(body, &results); err != nil {
return nil, fmt.Errorf("parse failed: %v", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("no results found")
}
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" && bestSynced == nil {
bestSynced = &results[i]
}
if results[i].PlainLyrics != "" && bestPlain == nil {
bestPlain = &results[i]
}
if bestSynced != nil {
break
}
}
best := bestSynced
if best == nil {
best = bestPlain
}
if best == nil {
best = &results[0]
}
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
return nil, fmt.Errorf("no lyrics found in search results")
}
return c.convertLRCLibToLyricsResponse(best), nil
}
func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
func isSynced(resp *LyricsResponse) bool {
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
}
func hasLyrics(resp *LyricsResponse) bool {
return resp != nil && !resp.Error && len(resp.Lines) > 0
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
var unsyncedFallback *LyricsResponse
var unsyncedSource string
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
return nil, "", false
}
if isSynced(resp) {
return resp, source, true
}
if unsyncedFallback == nil {
unsyncedFallback = resp
unsyncedSource = source
}
return nil, "", false
}
var resp *LyricsResponse
var src string
var found bool
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
resp, src, found = check(resp, nil, "LRCLIB")
if found {
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
return resp, src, nil
}
fmt.Printf(" LRCLIB exact (with album): no synced\n")
if albumName != "" {
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
resp, src, found = check(resp, nil, "LRCLIB (no album)")
if found {
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
return resp, src, nil
}
fmt.Printf(" LRCLIB exact (no album): no synced\n")
}
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
resp, src, found = check(resp, nil, "LRCLIB Search")
if found {
fmt.Printf(" [LRCLIB] Synced found via search\n")
return resp, src, nil
}
fmt.Printf(" LRCLIB search: no synced\n")
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
if found {
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
return resp, src, nil
}
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
if found {
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
return resp, src, nil
}
}
if unsyncedFallback != nil {
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
}
return nil, "", fmt.Errorf("lyrics not found in any source")
}
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
if line.StartTimeMs == "" {
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
} else {
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
}
return sb.String()
}
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
totalSeconds := ms / 1000
minutes := totalSeconds / 60
seconds := totalSeconds % 60
centiseconds := (ms % 1000) / 10
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".lrc"
}
func findAudioFileForLyrics(dir, trackName, artistName string) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
patterns := []string{
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
safeTitle,
}
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, pattern := range patterns {
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
ext := strings.ToLower(filepath.Ext(filename))
for _, audioExt := range audioExts {
if ext == strings.ToLower(audioExt) {
return filepath.Join(dir, filename)
}
}
}
}
}
return ""
}
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return &LyricsDownloadResponse{
Success: false,
Error: "Spotify ID is required",
}, fmt.Errorf("spotify ID is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist"
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
audioDuration := 0
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
if audioFile != "" {
duration, err := GetAudioDuration(audioFile)
if err == nil && duration > 0 {
audioDuration = int(duration)
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
}
}
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write LRC file: %v", err),
}, err
}
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics downloaded successfully",
File: filePath,
}, nil
}
+987
View File
@@ -0,0 +1,987 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
type Metadata struct {
Title string
Artist string
Album string
AlbumArtist string
Date string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string
Description string
ISRC string
Genre string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filepath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx = -1
for idx, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmtIdx = idx
break
}
}
cmt := flacvorbis.New()
if metadata.Title != "" {
_ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title)
}
if metadata.Artist != "" {
_ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist)
}
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
}
if metadata.TrackNumber > 0 {
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
}
if metadata.TotalTracks > 0 {
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.TotalDiscs > 0 {
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
}
if metadata.Copyright != "" {
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
}
if metadata.Publisher != "" {
_ = cmt.Add("PUBLISHER", metadata.Publisher)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
if metadata.ISRC != "" {
_ = cmt.Add("ISRC", metadata.ISRC)
}
if metadata.Genre != "" {
_ = cmt.Add("GENRE", metadata.Genre)
}
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[cmtIdx] = &cmtBlock
}
if coverPath != "" && fileExists(coverPath) {
if err := embedCoverArt(f, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed cover art: %v\n", err)
}
}
if err := f.Save(filepath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return nil
}
func embedCoverArt(f *flac.File, coverPath string) error {
imgData, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover image: %w", err)
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Cover",
imgData,
"image/jpeg",
)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
}
pictureBlock := picture.Marshal()
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
f.Meta = append(f.Meta, &pictureBlock)
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
f, err := flac.ParseFile(filepath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx = -1
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
for idx, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmtIdx = idx
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
existingCmt = nil
}
break
}
}
cmt := flacvorbis.New()
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
_ = cmt.Add(parts[0], parts[1])
}
}
}
}
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[cmtIdx] = &cmtBlock
}
if err := f.Save(filepath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return nil
}
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractCoverFromMp3(filePath)
case ".m4a", ".flac":
return extractCoverFromM4AOrFlac(filePath)
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
if len(pictures) == 0 {
return "", fmt.Errorf("no cover art found")
}
pic, ok := pictures[0].(id3v2.PictureFrame)
if !ok {
return "", fmt.Errorf("invalid picture frame")
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.Picture); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
if ext == ".flac" {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.ImageData); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
}
return "", fmt.Errorf("no cover art found")
}
return "", nil
}
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractLyricsFromMp3(filePath)
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
if len(usltFrames) == 0 {
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
return "", nil
}
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
if !ok {
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
return "", nil
}
if uslt.Lyrics == "" {
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
return "", nil
}
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
return uslt.Lyrics, nil
}
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
lyrics := parts[1]
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
return lyrics, nil
}
}
}
}
}
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
return "", nil
}
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Front cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "",
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func embedLyricsToM4A(filepath string, lyrics string) error {
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
"-map_metadata", "0",
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod",
"-y",
tmpOutputFile,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
return nil
}
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext {
case ".mp3":
return EmbedLyricsOnlyMP3(filepath, lyrics)
case ".flac":
return EmbedLyricsOnly(filepath, lyrics)
case ".m4a":
return embedLyricsToM4A(filepath, lyrics)
default:
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
}
}
func GetAudioDuration(filepath string) (float64, error) {
ext := strings.ToLower(pathfilepath.Ext(filepath))
if ext == ".flac" {
duration, err := getFlacDuration(filepath)
if err == nil && duration > 0 {
return duration, nil
}
}
return getDurationWithFFprobe(filepath)
}
func getFlacDuration(filepath string) (float64, error) {
f, err := flac.ParseFile(filepath)
if err != nil {
return 0, err
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
totalSamples := uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if sampleRate > 0 {
return float64(totalSamples) / float64(sampleRate), nil
}
}
}
}
return 0, fmt.Errorf("could not extract duration from FLAC file")
}
func getDurationWithFFprobe(filepath string) (float64, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return 0, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
filepath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return 0, err
}
var result struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &result); err != nil {
return 0, err
}
if result.Format.Duration == "" {
return 0, fmt.Errorf("duration not found in ffprobe output")
}
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
if err != nil {
return 0, err
}
return duration, nil
}
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
duration, err := GetAudioDuration(filepath)
if err != nil {
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
return lyrics, nil
}
if duration <= 0 {
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
return lyrics, nil
}
durationMs := int64(duration * 1000)
lines := strings.Split(lyrics, "\n")
var validLines []string
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
validLines = append(validLines, line)
continue
}
if strings.HasPrefix(trimmedLine, "[") {
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 {
if ms <= durationMs {
validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else {
validLines = append(validLines, line)
}
continue
}
} else {
validLines = append(validLines, line)
}
}
return strings.Join(validLines, "\n"), nil
}
func parseLRCTimestamp(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return -1
}
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
var metadata Metadata
ffprobePath, err := GetFFprobePath()
if err != nil {
return metadata, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return metadata, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return metadata, err
}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "date", "year":
if metadata.Date == "" || len(value) > len(metadata.Date) {
metadata.Date = value
}
case "track":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.TrackNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalTracks = num
}
}
case "disc":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.DiscNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalDiscs = num
}
}
case "copyright", "tcop":
metadata.Copyright = value
case "publisher", "tpub", "label":
metadata.Publisher = value
case "url":
metadata.URL = value
case "description", "comment":
if metadata.Description == "" {
metadata.Description = value
}
}
}
return metadata, nil
}
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".flac":
return EmbedMetadata(filePath, metadata, coverPath)
case ".mp3":
return embedMetadataToMP3(filePath, metadata, coverPath)
case ".m4a":
return embedMetadataToM4A(filePath, metadata, coverPath)
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames("TXXX")
if metadata.Title != "" {
tag.SetTitle(metadata.Title)
}
if metadata.Artist != "" {
tag.SetArtist(metadata.Artist)
}
if metadata.Album != "" {
tag.SetAlbum(metadata.Album)
}
if metadata.Date != "" {
year := metadata.Date
if len(year) >= 4 {
year = year[:4]
}
tag.SetYear(year)
}
if metadata.AlbumArtist != "" {
tag.DeleteFrames("TPE2")
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
}
if metadata.TrackNumber > 0 {
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
}
if metadata.DiscNumber > 0 {
tag.DeleteFrames(tag.CommonID("Part of a set"))
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
}
if metadata.Copyright != "" {
tag.DeleteFrames("TCOP")
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
}
if metadata.Publisher != "" {
tag.DeleteFrames("TPUB")
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
}
if metadata.ISRC != "" {
tag.DeleteFrames("TSRC")
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err == nil {
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
} else {
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
args := []string{
"-i", filePath,
"-y",
}
if coverPath != "" && fileExists(coverPath) {
args = append(args, "-i", coverPath)
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
} else {
args = append(args, "-map", "0", "-codec", "copy")
}
if metadata.Title != "" {
args = append(args, "-metadata", "title="+metadata.Title)
}
if metadata.Artist != "" {
args = append(args, "-metadata", "artist="+metadata.Artist)
}
if metadata.Album != "" {
args = append(args, "-metadata", "album="+metadata.Album)
}
if metadata.AlbumArtist != "" {
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
}
if metadata.Date != "" {
args = append(args, "-metadata", "date="+metadata.Date)
}
if metadata.TrackNumber > 0 {
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
args = append(args, "-metadata", "track="+trackStr)
}
if metadata.DiscNumber > 0 {
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
args = append(args, "-metadata", "disk="+discStr)
}
if metadata.Copyright != "" {
args = append(args, "-metadata", "copyright="+metadata.Copyright)
}
if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+metadata.Publisher)
}
if metadata.ISRC != "" {
args = append(args, "-metadata", "isrc="+metadata.ISRC)
}
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
args = append(args, "-f", "ipod", tmpOutputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var AppVersion = "Unknown"
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type MusicBrainzRecordingResponse struct {
Recordings []struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
Releases []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ReleaseGroup struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
} `json:"release-group"`
Date string `json:"date"`
Country string `json:"country"`
Media []struct {
Format string `json:"format"`
} `json:"media"`
LabelInfo []struct {
Label struct {
Name string `json:"name"`
} `json:"label"`
} `json:"label-info"`
} `json:"releases"`
ArtistCredit []struct {
Artist struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"artist"`
} `json:"artist-credit"`
Tags []struct {
Count int `json:"count"`
Name string `json:"name"`
} `json:"tags"`
} `json:"recordings"`
}
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
var meta Metadata
if !embedGenre {
return meta, nil
}
if isrc == "" {
return meta, fmt.Errorf("no ISRC provided")
}
client := &http.Client{
Timeout: 10 * time.Second,
}
query := fmt.Sprintf("isrc:%s", isrc)
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return meta, err
}
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion))
var resp *http.Response
var lastErr error
for i := 0; i < 3; i++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if i < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return meta, lastErr
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
var mbResp MusicBrainzRecordingResponse
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
return meta, err
}
if len(mbResp.Recordings) == 0 {
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
}
recording := mbResp.Recordings[0]
var genres []string
caser := cases.Title(language.English)
if useSingleGenre {
maxCount := -1
var bestTag string
for _, tag := range recording.Tags {
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = tag.Name
}
}
if bestTag != "" {
meta.Genre = caser.String(bestTag)
}
} else {
for _, tag := range recording.Tags {
genres = append(genres, caser.String(tag.Name))
}
if len(genres) > 0 {
if len(genres) > 5 {
genres = genres[:5]
}
meta.Genre = strings.Join(genres, GetSeparator())
}
}
return meta, nil
}
+419
View File
@@ -0,0 +1,419 @@
package backend
import (
"fmt"
"io"
"sync"
"time"
)
type DownloadStatus string
const (
StatusQueued DownloadStatus = "queued"
StatusDownloading DownloadStatus = "downloading"
StatusCompleted DownloadStatus = "completed"
StatusFailed DownloadStatus = "failed"
StatusSkipped DownloadStatus = "skipped"
)
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
SpotifyID string `json:"spotify_id"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"`
Speed float64 `json:"speed"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ErrorMessage string `json:"error_message"`
FilePath string `json:"file_path"`
}
var (
currentProgress float64
currentProgressLock sync.RWMutex
isDownloading bool
downloadingLock sync.RWMutex
currentSpeed float64
speedLock sync.RWMutex
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
currentItemLock sync.RWMutex
totalDownloaded float64
totalDownloadedLock sync.RWMutex
sessionStartTime int64
sessionStartLock sync.RWMutex
)
type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
}
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"`
TotalDownloaded float64 `json:"total_downloaded"`
SessionStartTime int64 `json:"session_start_time"`
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
downloadingLock.RUnlock()
currentProgressLock.RLock()
progress := currentProgress
currentProgressLock.RUnlock()
speedLock.RLock()
speed := currentSpeed
speedLock.RUnlock()
return ProgressInfo{
IsDownloading: downloading,
MBDownloaded: progress,
SpeedMBps: speed,
}
}
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
speedLock.Unlock()
}
func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock()
currentProgress = mbDownloaded
currentProgressLock.Unlock()
}
func SetDownloading(downloading bool) {
downloadingLock.Lock()
isDownloading = downloading
downloadingLock.Unlock()
if !downloading {
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
}
type ProgressWriter struct {
writer io.Writer
total int64
lastPrinted int64
startTime int64
lastTime int64
lastBytes int64
itemID string
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
now := getCurrentTimeMillis()
return &ProgressWriter{
writer: writer,
total: 0,
lastPrinted: 0,
startTime: now,
lastTime: now,
lastBytes: 0,
itemID: "",
}
}
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
return pw
}
func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli()
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total += int64(n)
if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024)
now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0
bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
if timeDiff > 0 {
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
SetDownloadProgress(mbDownloaded)
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
pw.lastPrinted = pw.total
pw.lastTime = now
pw.lastBytes = pw.total
}
return n, err
}
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
item := DownloadItem{
ID: id,
TrackName: trackName,
ArtistName: artistName,
AlbumName: albumName,
SpotifyID: spotifyID,
Status: StatusQueued,
Progress: 0,
TotalSize: 0,
Speed: 0,
StartTime: 0,
EndTime: 0,
}
downloadQueue = append(downloadQueue, item)
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
}
sessionStartLock.Unlock()
}
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusDownloading
downloadQueue[i].StartTime = time.Now().Unix()
downloadQueue[i].Progress = 0
break
}
}
currentItemLock.Lock()
currentItemID = id
currentItemLock.Unlock()
}
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Progress = progress
downloadQueue[i].Speed = speed
break
}
}
}
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusCompleted
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
break
}
}
}
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusFailed
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = errorMsg
break
}
}
}
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
break
}
}
}
func GetDownloadQueue() DownloadQueueInfo {
ResetSessionIfComplete()
downloadQueueLock.RLock()
defer downloadQueueLock.RUnlock()
downloadingLock.RLock()
downloading := isDownloading
downloadingLock.RUnlock()
speedLock.RLock()
speed := currentSpeed
speedLock.RUnlock()
totalDownloadedLock.RLock()
total := totalDownloaded
totalDownloadedLock.RUnlock()
sessionStartLock.RLock()
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
case StatusQueued:
queued++
case StatusCompleted:
completed++
case StatusFailed:
failed++
case StatusSkipped:
skipped++
}
}
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
return DownloadQueueInfo{
IsDownloading: downloading,
Queue: queueCopy,
CurrentSpeed: speed,
TotalDownloaded: total,
SessionStartTime: sessionStart,
QueuedCount: queued,
CompletedCount: completed,
FailedCount: failed,
SkippedCount: skipped,
}
}
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
newQueue = append(newQueue, item)
}
}
downloadQueue = newQueue
}
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
downloadQueueLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
currentItemLock.Lock()
currentItemID = ""
currentItemLock.Unlock()
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
}
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
hasActiveOrQueued = true
break
}
}
downloadQueueLock.RUnlock()
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
}
}
+515
View File
@@ -0,0 +1,515 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type QobuzDownloader struct {
client *http.Client
appID string
}
type QobuzSearchResponse struct {
Query string `json:"query"`
Tracks struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Duration int `json:"duration"`
TrackNumber int `json:"track_number"`
MediaNumber int `json:"media_number"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
MaximumBitDepth int `json:"maximum_bit_depth"`
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
Hires bool `json:"hires"`
HiresStreamable bool `json:"hires_streamable"`
ReleaseDateOriginal string `json:"release_date_original"`
Performer struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"performer"`
Album struct {
Title string `json:"title"`
ID string `json:"id"`
Image struct {
Small string `json:"small"`
Thumbnail string `json:"thumbnail"`
Large string `json:"large"`
} `json:"image"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Label struct {
Name string `json:"name"`
} `json:"label"`
} `json:"album"`
}
type QobuzStreamResponse struct {
URL string `json:"url"`
}
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
appID: "798273057",
}
}
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
resp, err := q.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to search track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var searchResp QobuzSearchResponse
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &searchResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return &searchResp.Tracks.Items[0], nil
}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qbz.afkarxyz.fun") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
}
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
resp, err := q.client.Get(apiURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if len(body) == 0 {
return "", fmt.Errorf("empty body")
}
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
return streamResp.URL, nil
}
var nestedResp struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
return nestedResp.Data.URL, nil
}
return "", fmt.Errorf("invalid response")
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
qualityCode := quality
if qualityCode == "" || qualityCode == "5" {
qualityCode = "6"
}
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qbz.afkarxyz.fun/api/track/",
}
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
Func func() (string, error)
}
var providers []Provider
for _, api := range standardAPIs {
currentAPI := api
providers = append(providers, Provider{
Name: "Standard(" + currentAPI + ")",
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
}
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
var lastErr error
for _, p := range providers {
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func()
if err == nil {
fmt.Printf("✓ Success\n")
return url, nil
}
fmt.Printf("Provider failed: %v\n", err)
lastErr = err
}
return "", lastErr
}
url, err := downloadFunc(qualityCode)
if err == nil {
return url, nil
}
currentQuality := qualityCode
if currentQuality == "27" && allowFallback {
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
url, err := downloadFunc("7")
if err == nil {
fmt.Println("✓ Success with fallback quality 7")
return url, nil
}
currentQuality = "7"
}
if currentQuality == "7" && allowFallback {
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
url, err := downloadFunc("6")
if err == nil {
fmt.Println("✓ Success with fallback quality 6")
return url, nil
}
}
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
}
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
downloadClient := &http.Client{
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
fmt.Printf("Creating file: %s\n", filepath)
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
fmt.Println("Downloading...")
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := q.client.Get(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create cover file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default:
filename = fmt.Sprintf("%s - %s", title, artist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
var deezerISRC string
if spotifyID != "" {
songlinkClient := NewSongLinkClient()
isrc, err := songlinkClient.GetISRC(spotifyID)
if err != nil {
return "", fmt.Errorf("failed to get ISRC: %v", err)
}
deezerISRC = isrc
} else {
return "", fmt.Errorf("spotify ID is required for Qobuz download")
}
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
metaChan := make(chan Metadata, 1)
if embedGenre && deezerISRC != "" {
go func() {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
fmt.Println("✓ MusicBrainz metadata fetched")
metaChan <- fetchedMeta
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
metaChan <- Metadata{}
}
}()
} else {
close(metaChan)
}
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.searchByISRC(deezerISRC)
if err != nil {
return "", err
}
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
qualityInfo := "Standard"
if track.Hires {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
}
fmt.Printf("Quality: %s\n", qualityInfo)
fmt.Println("Getting download URL...")
downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
if downloadURL == "" {
return "", fmt.Errorf("received empty download URL")
}
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
}
fmt.Printf("Download URL obtained: %s\n", urlPreview)
safeArtist := sanitizeFilename(artists)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(artists))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Printf("Downloading FLAC file to: %s\n", filepath)
if err := q.DownloadFile(downloadURL, filepath); err != nil {
return "", fmt.Errorf("failed to download file: %w", err)
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
var mbMeta Metadata
if deezerISRC != "" {
mbMeta = <-metaChan
}
fmt.Println("Embedding metadata and cover art...")
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: deezerISRC,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
return filepath, nil
}
+465
View File
@@ -0,0 +1,465 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type SongLinkClient struct {
client *http.Client
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
AmazonURL string `json:"amazon_url"`
ISRC string `json:"isrc"`
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
}
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
apiCallResetTime: time.Now(),
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", region)
}
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting streaming URLs from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
urls := &SongLinkURLs{}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
if isrc, err := getDeezerISRC(deezerLink.URL); err == nil && isrc != "" {
urls.ISRC = isrc
}
}
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
availability.Deezer = true
availability.DeezerURL = deezerURL
deezerISRC, err := getDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
}
return availability, nil
}
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
resp, err := client.Get(searchURL)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func getDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
parts := strings.Split(deezerURL, "/track/")
if len(parts) > 1 {
trackID = strings.Split(parts[1], "?")[0]
trackID = strings.TrimSpace(trackID)
}
}
if trackID == "" {
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return "", fmt.Errorf("failed to call Deezer API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
var deezerTrack struct {
ID int64 `json:"id"`
ISRC string `json:"isrc"`
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
}
if deezerTrack.ISRC == "" {
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
deezerURL, err := s.GetDeezerURLFromSpotify(spotifyID)
if err != nil {
return "", err
}
return getDeezerISRC(deezerURL)
}
+181
View File
@@ -0,0 +1,181 @@
package backend
import (
"fmt"
"math"
"math/cmplx"
"github.com/mewkiz/flac"
)
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
FreqBins int `json:"freq_bins"`
Duration float64 `json:"duration"`
MaxFreq float64 `json:"max_freq"`
}
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
stream, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
}
if len(samples) == 0 {
return nil, fmt.Errorf("no audio samples found")
}
return calculateSpectrum(samples, sampleRate), nil
}
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
sample /= float64(channels)
allSamples = append(allSamples, sample)
if len(allSamples) >= maxSamples {
return allSamples, nil
}
}
}
return allSamples, nil
}
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192
numTimeSlices := 300
duration := float64(len(samples)) / float64(sampleRate)
samplesPerSlice := len(samples) / numTimeSlices
if samplesPerSlice < fftSize {
samplesPerSlice = fftSize
numTimeSlices = len(samples) / fftSize
}
timeSlices := make([]TimeSlice, 0, numTimeSlices)
freqBins := fftSize / 2
maxFreq := float64(sampleRate) / 2.0
for i := 0; i < numTimeSlices; i++ {
startIdx := i * samplesPerSlice
if startIdx+fftSize > len(samples) {
break
}
window := samples[startIdx : startIdx+fftSize]
windowedSamples := applyHannWindow(window)
spectrum := fft(windowedSamples)
magnitudes := make([]float64, freqBins)
for j := 0; j < freqBins; j++ {
magnitude := cmplx.Abs(spectrum[j])
if magnitude < 1e-10 {
magnitude = 1e-10
}
magnitudes[j] = 20 * math.Log10(magnitude)
}
timeSlice := TimeSlice{
Time: float64(startIdx) / float64(sampleRate),
Magnitudes: magnitudes,
}
timeSlices = append(timeSlices, timeSlice)
}
return &SpectrumData{
TimeSlices: timeSlices,
SampleRate: sampleRate,
FreqBins: freqBins,
Duration: duration,
MaxFreq: maxFreq,
}
}
func applyHannWindow(samples []float64) []float64 {
n := len(samples)
windowed := make([]float64, n)
for i := 0; i < n; i++ {
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
windowed[i] = samples[i] * window
}
return windowed
}
func fft(samples []float64) []complex128 {
n := len(samples)
x := make([]complex128, n)
for i := 0; i < n; i++ {
x[i] = complex(samples[i], 0)
}
return fftRecursive(x)
}
func fftRecursive(x []complex128) []complex128 {
n := len(x)
if n <= 1 {
return x
}
even := make([]complex128, n/2)
odd := make([]complex128, n/2)
for i := 0; i < n/2; i++ {
even[i] = x[2*i]
odd[i] = x[2*i+1]
}
evenFFT := fftRecursive(even)
oddFFT := fftRecursive(odd)
result := make([]complex128, n)
for k := 0; k < n/2; k++ {
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
result[k] = evenFFT[k] + t
result[k+n/2] = evenFFT[k] - t
}
return result
}
+1782
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
if !useAPI || apiBaseURL == "" {
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
}
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
if spotifyType == "" || id == "" {
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
}
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create API request: %w", err)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read API response: %w", err)
}
var data interface{}
switch spotifyType {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
data = trackResp
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
data = &albumResp
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
data = playlistResp
case "artist":
var artistResp ArtistDiscographyPayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
data = &artistResp
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
}
return data, nil
}
func parseSpotifyURLToTypeAndID(url string) (string, string) {
if strings.HasPrefix(url, "spotify:") {
parts := strings.Split(url, ":")
if len(parts) >= 3 {
return parts[1], parts[2]
}
}
re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`)
matches := re.FindStringSubmatch(url)
if len(matches) == 3 {
return matches[1], matches[2]
}
return "", ""
}
File diff suppressed because it is too large Load Diff
+1059
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

-237
View File
@@ -1,237 +0,0 @@
import requests
import asyncio
import os
import sys
from mutagen.flac import FLAC
class DeezerDownloader:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
self.progress_callback = None
def set_progress_callback(self, callback):
self.progress_callback = callback
def get_track_by_isrc(self, isrc):
try:
url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
response = self.session.get(url)
response.raise_for_status()
data = response.json()
if 'error' in data:
print(f"Error from Deezer API: {data['error']['message']}")
return None
return data
except requests.exceptions.RequestException as e:
print(f"Error fetching track data: {e}")
return None
def extract_metadata(self, track_data):
metadata = {}
metadata['title'] = track_data.get('title', '')
metadata['title_short'] = track_data.get('title_short', '')
metadata['duration'] = track_data.get('duration', 0)
metadata['track_position'] = track_data.get('track_position', 1)
metadata['disk_number'] = track_data.get('disk_number', 1)
metadata['isrc'] = track_data.get('isrc', '')
metadata['release_date'] = track_data.get('release_date', '')
metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
if 'artist' in track_data:
metadata['artist'] = track_data['artist'].get('name', '')
metadata['artist_id'] = track_data['artist'].get('id', '')
if 'contributors' in track_data:
artists = []
for contributor in track_data['contributors']:
if contributor.get('role') == 'Main':
artists.append(contributor.get('name', ''))
metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
if 'album' in track_data:
album = track_data['album']
metadata['album'] = album.get('title', '')
metadata['album_id'] = album.get('id', '')
metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
metadata['cover_md5'] = album.get('md5_image', '')
metadata['deezer_link'] = track_data.get('link', '')
metadata['preview_url'] = track_data.get('preview', '')
return metadata
def download_cover_art(self, cover_url, filename):
if not cover_url:
return None
try:
response = self.session.get(cover_url)
response.raise_for_status()
cover_path = f"{filename}_cover.jpg"
with open(cover_path, 'wb') as f:
f.write(response.content)
return cover_path
except Exception as e:
print(f"Error downloading cover art: {e}")
return None
def embed_metadata(self, file_path, metadata, cover_path=None):
try:
audio = FLAC(file_path)
audio.clear()
if metadata.get('title'):
audio['TITLE'] = metadata['title']
if metadata.get('artists'):
audio['ARTIST'] = metadata['artists']
elif metadata.get('artist'):
audio['ARTIST'] = metadata['artist']
if metadata.get('album'):
audio['ALBUM'] = metadata['album']
if metadata.get('release_date'):
audio['DATE'] = metadata['release_date']
if metadata.get('track_position'):
audio['TRACKNUMBER'] = str(metadata['track_position'])
if metadata.get('disk_number'):
audio['DISCNUMBER'] = str(metadata['disk_number'])
if metadata.get('isrc'):
audio['ISRC'] = metadata['isrc']
if cover_path and os.path.exists(cover_path):
with open(cover_path, 'rb') as f:
cover_data = f.read()
from mutagen.flac import Picture
picture = Picture()
picture.type = 3
picture.mime = 'image/jpeg'
picture.desc = 'Cover'
picture.data = cover_data
audio.add_picture(picture)
audio.save()
print(f"Metadata embedded successfully in {file_path}")
except Exception as e:
print(f"Error embedding metadata: {e}")
async def download_by_isrc(self, isrc, output_dir="."):
print(f"Fetching track info for ISRC: {isrc}")
track_data = self.get_track_by_isrc(isrc)
if not track_data:
print("Failed to get track data from Deezer API")
return False
metadata = self.extract_metadata(track_data)
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
track_id = track_data.get('id')
if not track_id:
print("No track ID found in Deezer API response")
return False
print(f"Using track ID: {track_id}")
api_url = f"https://api.deezmate.com/dl/{track_id}"
print(f"Requesting download links from: {api_url}")
try:
response = self.session.get(api_url)
response.raise_for_status()
api_data = response.json()
if not api_data.get('success'):
print("API request failed")
return False
links = api_data.get('links', {})
flac_url = links.get('flac')
if not flac_url:
print("No FLAC download link found in API response")
return False
print(f"Successfully obtained FLAC download URL")
except Exception as e:
print(f"Error getting download URL from API: {e}")
return False
print("Downloading FLAC file...")
try:
response = self.session.get(flac_url)
response.raise_for_status()
safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
filename = f"{safe_artist} - {safe_title}.flac"
file_path = os.path.join(output_dir, filename)
with open(file_path, 'wb') as f:
f.write(response.content)
downloaded = len(response.content)
print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)")
if self.progress_callback:
self.progress_callback(downloaded, downloaded)
print(f"Downloaded: {file_path}")
cover_path = None
if metadata.get('cover_url'):
print("Downloading cover art...")
cover_path = self.download_cover_art(metadata['cover_url'],
os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
print("Embedding metadata...")
self.embed_metadata(file_path, metadata, cover_path)
if cover_path and os.path.exists(cover_path):
os.remove(cover_path)
print(f"Successfully downloaded and tagged: {filename}")
return True
except Exception as e:
print(f"Error downloading file: {e}")
return False
async def main():
print("=== DeezerDL - Deezer Downloader ===")
downloader = DeezerDownloader()
isrc = "USAT22409172"
output_dir = "."
success = await downloader.download_by_isrc(isrc, output_dir)
if success:
print("Download completed successfully!")
else:
print("Download failed!")
if __name__ == "__main__":
try:
import sys
if sys.platform == "win32":
import os
os.system("chcp 65001 > nul")
try:
sys.stdout.reconfigure(encoding='utf-8')
except:
pass
except:
pass
asyncio.run(main())
-28
View File
@@ -1,28 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
<defs>
<g id="eu-d">
<g id="eu-b">
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
</g>
<g id="eu-c">
<use xlink:href="#eu-b" transform="rotate(72)"/>
<use xlink:href="#eu-b" transform="rotate(144)"/>
</g>
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
</g>
</defs>
<path fill="#039" d="M0 0h640v480H0z"/>
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
<g id="eu-e">
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
</g>
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
}
}
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"postinstall": "node scripts/generate-icon.js",
"lint": "eslint .",
"preview": "vite preview",
"generate-icon": "node scripts/generate-icon.js"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"motion": "^12.34.3",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.3.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
+1
View File
@@ -0,0 +1 @@
867c45db7982e126a7249d80210f23be
+4516
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #733e0a;
}
.cls-2 {
fill: #fdc700;
}
.cls-3 {
fill: #1ed760;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g>
<g id="_1818452274576">
<g id="SVGRepo_iconCarrier">
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
</g>
</g>
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
<g>
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+24
View File
@@ -0,0 +1,24 @@
import sharp from 'sharp';
import { readFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..', '..');
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
const outputPath = join(rootDir, 'build', 'appicon.png');
async function generateIcon() {
try {
mkdirSync(join(rootDir, 'build'), { recursive: true });
const svgBuffer = readFileSync(svgPath);
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(outputPath);
console.log('✓ Icon generated:', outputPath);
}
catch (error) {
console.error('✗ Failed to generate icon:', error.message);
process.exit(1);
}
}
generateIcon();
+592
View File
@@ -0,0 +1,592 @@
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { AboutPage } from "@/components/AboutPage";
import { HistoryPage } from "@/components/HistoryPage";
import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default");
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
useEffect(() => {
localStorage.setItem("spotiflac_region", region);
}, [region]);
const [showScrollTop, setShowScrollTop] = useState(false);
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = __APP_VERSION__;
const download = useDownload(region);
const metadata = useMetadata();
const lyrics = useLyrics();
const cover = useCover();
const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
const downloadProgress = useDownloadProgress();
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
useLayoutEffect(() => {
const savedSettings = getSettings();
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
}
}, []);
useEffect(() => {
const initSettings = async () => {
const settings = await loadSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
await saveSettings(settingsWithDefaults);
}
};
initSettings();
const checkFFmpeg = async () => {
try {
const installed = await CheckFFmpegInstalled();
setIsFFmpegInstalled(installed);
}
catch (err) {
console.error("Failed to check FFmpeg:", err);
setIsFFmpegInstalled(false);
}
};
checkFFmpeg();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
const currentSettings = getSettings();
if (currentSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(currentSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
loadHistory();
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
};
}, []);
const handleEnableSpotFetchApi = async () => {
try {
await updateSettings({ useSpotFetchAPI: true });
metadata.setShowApiModal(false);
toast.success("SpotFetch API enabled! You can now try fetching again.");
}
catch (err) {
console.error("Failed to enable SpotFetch API:", err);
toast.error("Failed to update settings");
}
};
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
download.resetDownloadedTracks();
lyrics.resetLyricsState();
cover.resetCoverState();
availability.clearAvailability();
setSortBy("default");
setCurrentListPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
const data = await response.json();
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
}
catch (err) {
console.error("Failed to check for updates:", err);
}
};
const loadHistory = () => {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
setFetchHistory(JSON.parse(saved));
}
}
catch (err) {
console.error("Failed to load history:", err);
}
};
const handleInstallFFmpeg = async () => {
setIsInstallingFFmpeg(true);
setFfmpegInstallProgress(0);
setFfmpegInstallStatus("starting");
try {
EventsOn("ffmpeg:progress", (progress: number) => {
setFfmpegInstallProgress(progress);
if (progress >= 100) {
setFfmpegInstallStatus("extracting");
}
else {
setFfmpegInstallStatus("downloading");
}
});
EventsOn("ffmpeg:status", (status: string) => {
setFfmpegInstallStatus(status);
});
const response = await DownloadFFmpeg();
EventsOff("ffmpeg:progress");
EventsOff("ffmpeg:status");
if (response.success) {
toast.success("FFmpeg installed successfully!");
setIsFFmpegInstalled(true);
}
else {
toast.error(`Failed to install FFmpeg: ${response.error}`);
}
}
catch (error) {
console.error("Error installing FFmpeg:", error);
toast.error(`Error during FFmpeg installation: ${error}`);
}
finally {
setIsInstallingFFmpeg(false);
setFfmpegInstallProgress(0);
setFfmpegInstallStatus("");
}
};
const saveHistory = (history: HistoryItem[]) => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
catch (err) {
console.error("Failed to save history:", err);
}
};
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
setFetchHistory((prev) => {
const filtered = prev.filter((h) => h.url !== item.url);
const newItem: HistoryItem = {
...item,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
const removeFromHistory = (id: string) => {
setFetchHistory((prev) => {
const updated = prev.filter((h) => h.id !== id);
saveHistory(updated);
return updated;
});
};
const handleHistorySelect = async (item: HistoryItem) => {
setSpotifyUrl(item.url);
const updatedUrl = await metadata.handleFetchMetadata(item.url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
const handleFetchMetadata = async () => {
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
useEffect(() => {
if (!metadata.metadata || !spotifyUrl)
return;
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "track",
name: track.name,
artist: track.artists,
image: track.images,
};
}
else if ("album_info" in metadata.metadata) {
const { album_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "album",
name: album_info.name,
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
image: album_info.images,
};
}
else if ("playlist_info" in metadata.metadata) {
const { playlist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "playlist",
name: playlist_info.owner.name,
artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
image: playlist_info.cover || playlist_info.owner.images || "",
};
}
else if ("artist_info" in metadata.metadata) {
const { artist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "artist",
name: artist_info.name,
artist: `${artist_info.total_albums.toLocaleString()} albums`,
image: artist_info.images,
};
}
if (historyItem) {
addToHistory(historyItem);
}
}, [metadata.metadata]);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentListPage(1);
};
const toggleTrackSelection = (id: string) => {
setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]);
};
const toggleSelectAll = (tracks: any[]) => {
const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || "");
if (tracksWithId.length === 0)
return;
const allSelected = tracksWithId.every(id => selectedTracks.includes(id));
if (allSelected) {
setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id)));
}
else {
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId])));
}
};
const handleOpenFolder = async () => {
const settings = getSettings();
if (!settings.downloadPath) {
toast.error("Download path not set");
return;
}
try {
await OpenFolder(settings.downloadPath);
}
catch (error) {
console.error("Error opening folder:", error);
toast.error(`Error opening folder: ${error}`);
}
};
const renderMetadata = () => {
if (!metadata.metadata)
return null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
const trackId = track.spotify_id || "";
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
return null;
};
const handlePageChange = (page: PageType) => {
if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") {
setPendingPageChange(page);
setShowUnsavedChangesDialog(true);
return;
}
setCurrentPage(page);
};
const handleDiscardChanges = () => {
setShowUnsavedChangesDialog(false);
if (resetSettingsFn) {
resetSettingsFn();
}
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
}
};
const handleCancelNavigation = () => {
setShowUnsavedChangesDialog(false);
setPendingPageChange(null);
};
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug":
return <DebugLoggerPage />;
case "about":
return <AboutPage />;
case "history":
return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
setCurrentPage("main");
}}/>;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
return <AudioConverterPage />;
case "file-manager":
return <FileManagerPage />;
default:
return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
{metadata.selectedAlbum && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={async () => {
const albumUrl = await metadata.handleConfirmAlbumFetch();
if (albumUrl) {
setSpotifyUrl(albumUrl);
}
}}>
<Search className="h-4 w-4"/>
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/>
{!isSearchMode && metadata.metadata && renderMetadata()}
</>);
}
};
return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{renderPage()}
</div>
</div>
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5"/>
</Button>)}
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelNavigation}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-[360px] [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required
</DialogTitle>
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
FFmpeg is essential for SpotiFLAC to function properly.
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
</DialogDescription>
</DialogHeader>
{isInstallingFFmpeg && (<div className="space-y-4">
{ffmpegInstallStatus === "extracting" ? (<div className="flex flex-col items-center justify-center py-2 animate-in fade-in duration-500">
<div className="flex items-center gap-3">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
<span className="text-sm font-bold tracking-tight">Extracting...</span>
</div>
<span className="text-[10px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">Finalizing setup</span>
</div>) : (<div className="space-y-3">
<div className="flex justify-between text-[11px] font-bold">
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground uppercase tracking-wider">Downloading...</span>
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-primary font-mono tabular-nums">
{downloadProgress.mb_downloaded.toFixed(1)}MB
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`}
</span>)}
</div>
<span className="text-xl font-bold tracking-tighter text-primary">{ffmpegInstallProgress}%</span>
</div>
<div className="h-1.5 w-full bg-secondary/30 rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(var(--primary),0.3)]" style={{ width: `${ffmpegInstallProgress}%` }}/>
</div>
</div>)}
</div>)}
<DialogFooter className="flex-row gap-3 pt-2">
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
Exit
</Button>)}
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={handleInstallFFmpeg} disabled={isInstallingFFmpeg}>
{isInstallingFFmpeg ? "Installing..." : "Install now"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={metadata.showApiModal} onOpenChange={metadata.setShowApiModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>SpotFetch API Recommended</DialogTitle>
<DialogDescription>
Direct fetch failed. This usually happens when your <span className="text-foreground font-bold">country is blocked</span> by Spotify or your IP is restricted. Would you like to enable the <span className="text-foreground font-bold">SpotFetch API</span> to bypass this?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowApiModal(false)}>
Cancel
</Button>
<Button onClick={handleEnableSpotFetchApi}>
Enable SpotFetch API
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+18
View File
@@ -0,0 +1,18 @@
export const langColors: Record<string, string> = {
"TypeScript": "#2b7489",
"Go": "#375eab",
"Python": "#3572A5",
"CSS": "#563d7c",
"HTML": "#e44b23",
"JavaScript": "#f1e05a",
"Java": "#b07219",
"C": "#555555",
"C Sharp": "#178600",
"cpp": "#f34b7d",
"Ruby": "#701516",
"PHP": "#4F5D95",
"Swift": "#ffac45",
"Kotlin": "#F18E33",
"Rust": "#dea584",
"Shell": "#89e051"
};
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.8.3, SVG Export Plug-In . SVG Version: 2.1.1 Build 3) -->
<defs>
<style>
.st0 {
fill: #733e0a;
}
.st1 {
fill: #fdc700;
}
.st2 {
fill: #1ed760;
}
</style>
</defs>
<path class="st2" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0v.1ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1v.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
<path class="st1" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1h0Z"/>
<g>
<path class="st0" d="M70.76,465.35v-77.71h23.4l50.61,59.75-5.66,1.31v-61.06h18.83v77.71h-23.51l-49.52-58.45,4.57-1.74v60.19h-18.72Z"/>
<path class="st0" d="M171.65,465.35v-77.71h76.51v15.78h-55.73v15.24h51.48v15.13h-51.48v15.78h55.73v15.78h-76.51Z"/>
<path class="st0" d="M254.8,465.35l41.47-45.17-2.39,9.25-37.33-41.79h26.34l28.08,32.65-13.17-.44,29.17-32.22h23.51l-39.07,42.01.65-8.82,39.72,44.51h-26.23l-29.82-34.72,14.26-.65-31.56,35.37h-23.62Z"/>
<path class="st0" d="M387.8,465.35v-62.04h-32.76v-15.67h86.2v15.67h-32.65v62.04h-20.79Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #2dc261;
fill-rule: evenodd;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g id="SVGRepo_iconCarrier">
<g id="Page-1" sketch:type="MSPage">
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #733e0a;
}
.cls-2 {
fill: #fdc700;
}
.cls-3 {
fill: #1ed760;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g>
<g id="_1818452274576">
<g id="SVGRepo_iconCarrier">
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
</g>
</g>
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
<g>
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#00bc7d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" /></svg>

After

Width:  |  Height:  |  Size: 448 B

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
id="Layer_1" width="512px" height="512px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;"
xml:space="preserve">
<g fill="#1da0f1">
<polygon
points="12.153992,10.729553 8.088684,5.041199 5.92041,5.041199 10.956299,12.087097 11.59021,12.97345 15.900635,19.009583 18.068909,19.009583 12.785217,11.615906 " />
<path
d="M21.15979,1H2.84021C1.823853,1,1,1.823853,1,2.84021v18.31958C1,22.176147,1.823853,23,2.84021,23h18.31958 C22.176147,23,23,22.176147,23,21.15979V2.84021C23,1.823853,22.176147,1,21.15979,1z M15.235352,20l-4.362549-6.213013 L5.411438,20H4l6.246887-7.104675L4,4h4.764648l4.130127,5.881958L18.06958,4h1.411377l-5.95697,6.775635L20,20H15.235352z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
</mask>
<g mask="url(#mask0_1_219)">
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+382
View File
@@ -0,0 +1,382 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors";
export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
const [copiedUsdt, setCopiedUsdt] = useState(false);
useEffect(() => {
const fetchRepoStats = async () => {
const CACHE_KEY = "github_repo_stats";
const CACHE_DURATION = 1000 * 60 * 60;
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_DURATION) {
setRepoStats(data);
return;
}
}
catch (err) {
console.error("Failed to parse cache:", err);
}
}
const repos = [
{ name: "SpotiDownloader", owner: "afkarxyz" },
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
];
const stats: Record<string, any> = {};
for (const repo of repos) {
try {
const [repoRes, releasesRes, langsRes] = await Promise.all([
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`),
]);
if (repoRes.status === 403) {
if (cached) {
const { data } = JSON.parse(cached);
setRepoStats(data);
}
return;
}
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
const repoData = await repoRes.json();
const releases = await releasesRes.json();
const languages = await langsRes.json();
let totalDownloads = 0;
let latestDownloads = 0;
let latestVersion = "";
if (releases.length > 0) {
latestVersion = releases[0].tag_name || "";
latestDownloads =
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
totalDownloads = releases.reduce((sum: number, release: any) => {
return (sum +
(release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0));
}, 0);
}
const topLangs = Object.entries(languages)
.sort(([, a]: any, [, b]: any) => b - a)
.slice(0, 4)
.map(([lang]) => lang);
stats[repo.name] = {
stars: repoData.stargazers_count,
forks: repoData.forks_count,
createdAt: repoData.created_at,
totalDownloads,
latestDownloads,
latestVersion,
languages: topLangs,
};
}
}
catch (err) {
console.error(`Failed to fetch stats for ${repo.name}:`, err);
if (cached) {
const { data } = JSON.parse(cached);
setRepoStats(data);
return;
}
}
}
setRepoStats(stats);
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() }));
};
fetchRepoStats();
}, []);
const formatTimeAgo = (dateString: string): string => {
const now = new Date();
const updated = new Date(dateString);
const diffMs = now.getTime() - updated.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30);
if (diffDays === 0)
return "today";
if (diffDays === 1)
return "1d";
if (diffDays < 30)
return `${diffDays}d`;
if (diffMonths === 1)
return "1mo";
if (diffMonths < 12)
return `${diffMonths}mo`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears}y`;
};
const formatNumber = (num: number): string => {
if (num >= 1000) {
return num.toLocaleString();
}
return num.toString();
};
const getLangColor = (lang: string): string => {
return langColors[lang] || "#858585";
};
return (<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/>
Support Me
</Button>
</div>
<div className="flex-1 min-h-0">
{activeTab === "projects" && (<div className="p-1 pr-2">
<div className="grid gap-2 grid-cols-4">
<div className="flex flex-col gap-2 h-full">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex gap-3 pt-2">
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
SpotubeDL
</CardTitle>
<CardDescription>
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
with High Quality.
</CardDescription>
</CardHeader>
</Card>
</div>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<div className="flex justify-between items-start mb-2">
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
{repoStats["SpotiDownloader"].latestVersion}
</span>)}
</div>
<CardTitle className="leading-tight">
SpotiDownloader
</CardTitle>
<CardDescription>
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
</CardDescription>
</CardHeader>
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["SpotiDownloader"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["SpotiDownloader"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["SpotiDownloader"].createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
<CardHeader>
<div className="flex justify-between items-start mb-2">
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
{repoStats["SpotiFLAC-Next"].latestVersion}
</span>)}
</div>
<CardTitle className="leading-tight">
SpotiFLAC Next
</CardTitle>
<CardDescription>
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</CardDescription>
</CardHeader>
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiFLAC-Next"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["SpotiFLAC-Next"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<div className="flex justify-between items-start mb-2">
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
</span>)}
</div>
<CardTitle className="leading-tight">
Twitter/X Media Batch Downloader
</CardTitle>
<CardDescription>
A GUI tool to download original-quality images and videos
from Twitter/X accounts, powered by gallery-dl by @mikf
</CardDescription>
</CardHeader>
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["Twitter-X-Media-Batch-Downloader"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
.createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Enjoying the project? You can support ongoing development by buying me a coffee.
</p>
</div>
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
Support on Ko-fi
</Button>
</div>
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="p-2 bg-white rounded-xl shadow-sm border">
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Crypto donations are also accepted. Scan the QR code or copy the address.
</p>
</div>
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
</div>)}
</div>
</div>);
}
+224
View File
@@ -0,0 +1,224 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import { getSettings } from "@/lib/settings";
import { downloadCover } from "@/lib/api";
import { useState } from "react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { parseTemplate, type TemplateData } from "@/lib/settings";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
artist_id?: string;
artist_url?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
const settings = getSettings();
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
const handleDownloadAlbumCover = async () => {
if (!albumInfo.images)
return;
setDownloadingAlbumCover(true);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const albumName = albumInfo.name;
const artistName = albumInfo.artists;
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
album_artist: artistName?.replace(/\//g, placeholder),
title: albumName?.replace(/\//g, placeholder),
year: albumInfo.release_date?.substring(0, 4),
date: albumInfo.release_date,
};
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
}
}
}
const response = await downloadCover({
cover_url: albumInfo.images,
track_name: albumName,
artist_name: "",
album_name: "",
album_artist: "",
release_date: "",
output_dir: outputDir,
filename_format: "title",
track_number: false,
position: 0,
disc_number: 0,
});
if (response.success) {
if (response.already_exists)
toast.info("Cover already exists");
else
toast.success("Album cover downloaded");
}
else {
toast.error(response.error || "Failed to download cover");
}
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download cover");
}
finally {
setDownloadingAlbumCover(false);
}
};
return (<div className="space-y-6">
<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{albumInfo.images && (<div className="relative group shrink-0 w-48 h-48">
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent><p>Download Album Cover</p></TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})}>
{albumInfo.artists}
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
<span>
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={true} folderName={albumInfo.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>);
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface ApiSource {
id: string;
type: string;
name: string;
url: string;
}
const SOURCES: ApiSource[] = [
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.fun" },
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.fun" },
];
export function ApiStatusTab() {
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
const [isCheckingAll, setIsCheckingAll] = useState(false);
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
try {
const isOnline = await CheckAPIStatus(apiType, url);
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
}
catch (error) {
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
}
};
const checkAll = async () => {
setIsCheckingAll(true);
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
await Promise.allSettled(promises);
setIsCheckingAll(false);
};
useEffect(() => {
checkAll();
}, []);
return (<div className="space-y-6">
<div className="flex items-center justify-end">
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
Refresh All
</Button>
</div>
<div className="grid grid-cols-4 gap-4">
{SOURCES.map((source) => {
const status = statuses[source.id] || "idle";
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center gap-3">
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
</div>
</div>);
})}
</div>
</div>);
}
+579
View File
@@ -0,0 +1,579 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useState, useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ArtistInfoProps {
artistInfo: {
name: string;
images: string;
header?: string;
gallery?: string[];
followers: number;
genres: string[];
biography?: string;
verified?: boolean;
listeners?: number;
rank?: number;
};
albumList: Array<{
id: string;
name: string;
images: string;
release_date: string;
album_type: string;
external_urls: string;
total_tracks?: number;
}>;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onPageChange: (page: number) => void;
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
const filteredAlbumGroups = useMemo(() => {
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
const albumGroups = trackList.reduce((acc, track) => {
if (!track.album_name)
return acc;
if (!acc[track.album_name]) {
acc[track.album_name] = {
count: 0,
tracks: [],
type: albumTypeMap.get(track.album_name) || "unknown"
};
}
acc[track.album_name].count++;
acc[track.album_name].tracks.push(track);
return acc;
}, {} as Record<string, {
count: number;
tracks: TrackMetadata[];
type: string;
}>);
return Object.entries(albumGroups).sort((a, b) => {
const dateA = a[1].tracks[0]?.release_date || "";
const dateB = b[1].tracks[0]?.release_date || "";
return dateB.localeCompare(dateA);
});
}, [trackList, albumList]);
const handleDownloadHeader = async () => {
if (!artistInfo.header)
return;
setDownloadingHeader(true);
try {
const settings = getSettings();
const response = await downloadHeader({
header_url: artistInfo.header,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Header already exists");
}
else {
toast.success("Header downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download header");
}
}
catch (error) {
toast.error(`Error downloading header: ${error}`);
}
finally {
setDownloadingHeader(false);
}
};
const handleDownloadAvatar = async () => {
if (!artistInfo.images)
return;
setDownloadingAvatar(true);
try {
const settings = getSettings();
const response = await downloadAvatar({
avatar_url: artistInfo.images,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Avatar already exists");
}
else {
toast.success("Avatar downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download avatar");
}
}
catch (error) {
toast.error(`Error downloading avatar: ${error}`);
}
finally {
setDownloadingAvatar(false);
}
};
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
setDownloadingGalleryIndex(index);
try {
const settings = getSettings();
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info(`Gallery image ${index + 1} already exists`);
}
else {
toast.success(`Gallery image ${index + 1} downloaded successfully`);
}
}
else {
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
}
}
catch (error) {
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
}
finally {
setDownloadingGalleryIndex(null);
}
};
const handleDownloadAllGallery = async () => {
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
return;
setDownloadingAllGallery(true);
try {
const settings = getSettings();
let successCount = 0;
let existsCount = 0;
let failCount = 0;
for (let index = 0; index < artistInfo.gallery.length; index++) {
const imageUrl = artistInfo.gallery[index];
try {
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
existsCount++;
}
else {
successCount++;
}
}
else {
failCount++;
}
}
catch (error) {
failCount++;
}
}
if (failCount === 0) {
if (existsCount > 0 && successCount > 0) {
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
}
else if (existsCount > 0) {
toast.info(`All ${existsCount} images already exist`);
}
else {
toast.success(`All ${successCount} gallery images downloaded successfully`);
}
}
else {
toast.error(`${failCount} images failed to download`);
}
}
catch (error) {
toast.error(`Error downloading gallery images: ${error}`);
}
finally {
setDownloadingAllGallery(false);
}
};
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
return (<div className="space-y-6">
<Card className="overflow-hidden p-0 relative">
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<div className="absolute bottom-4 right-4 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingHeader ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Header</p>
</TooltipContent>
</Tooltip>
</div>
<div className="relative px-6 pt-6 pb-20">
<div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-white/80">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
{artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)}
</div>
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span>
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</div>
</div>
</>) : (<CardContent className="px-6 py-6">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap">
{artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)}
</div>
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span>
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</CardContent>)}
</Card>
<div className="border-b">
<div className="flex gap-6">
<button onClick={() => setActiveTab("albums")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "albums" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Albums
</button>
<button onClick={() => setActiveTab("tracks")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "tracks" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
All Tracks
</button>
{hasGallery && (<button onClick={() => setActiveTab("gallery")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "gallery" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Gallery
</button>)}
</div>
</div>
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Gallery</p>
</TooltipContent>
</Tooltip>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Image {index + 1}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>))}
</div>
</div>)}
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="flex gap-2">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Discography
</Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length})
</Button>)}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{albumList.map((album) => {
const albumTracks = trackList.filter(t => t.album_name === album.name);
const tracksWithId = albumTracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
const hasTracks = tracksWithId.length > 0;
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})}>
<div className="relative mb-2">
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
</div>)}
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
<div className="absolute bottom-2 right-2">
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
{album.album_type}
</span>
</div>
</div>
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{album.release_date?.split("-")[0]}</span>
{album.total_tracks && (<>
<span></span>
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
</>)}
</div>
</div>);
})}
</div>
</div>)}
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">All Tracks</h3>
<div className="flex gap-2 flex-wrap">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4"/>
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
{filteredAlbumGroups.map(([albumName, data]) => {
const tracksWithId = data.tracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
<div className="grid gap-1.5 leading-none flex-1">
<label htmlFor={`album-select-${albumName}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
{albumName}
</label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize bg-muted px-1.5 py-0.5 rounded text-[10px] font-semibold border">
{data.type}
</span>
<span></span>
<span>{data.count} tracks</span>
<span></span>
<span>{data.tracks[0]?.release_date?.split('-')[0] || 'Unknown Year'}</span>
</div>
</div>
</div>);
})}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>)}
</div>);
}
+125
View File
@@ -0,0 +1,125 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
result: AnalysisResult | null;
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
filePath?: string;
}
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
if (analyzing) {
return (<Card>
<CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3">
<Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span>
</div>
</CardContent>
</Card>);
}
if (!result && showAnalyzeButton) {
return (<Card>
<CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-primary"/>
<div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files
</p>
</div>
{onAnalyze && (<Button onClick={onAnalyze}>
<Activity className="h-4 w-4"/>
Analyze Audio
</Button>)}
</div>
</CardContent>
</Card>);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
const nyquistFreq = result.sample_rate / 2;
return (<Card className="gap-2">
<CardHeader>
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Sample Rate:</span>
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
</div>
<div className="flex items-center gap-1">
<FileAudio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Bit Depth:</span>
<span className="font-semibold">{result.bit_depth}</span>
</div>
<div className="flex items-center gap-1">
<Waves className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Channels:</span>
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold">{formatDuration(result.duration)}</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div>
{result.file_size > 0 && (<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div>)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Dynamic Range:</span>
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Peak:</span>
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">RMS:</span>
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
</div>
<div className="flex items-center gap-1 ml-auto">
<span className="text-muted-foreground">Samples:</span>
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>);
}
@@ -0,0 +1,113 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack?: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
await analyzeFile(filePath);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
await analyzeFile(filePath);
}, [analyzeFile]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
};
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5"/>
</Button>)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4"/>
Clear
</Button>)}
</div>
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5"/>
Select FLAC File
</Button>
</div>)}
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>)}
{result && (<div className="space-y-4">
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
</div>)}
</div>);
}
@@ -0,0 +1,460 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile {
path: string;
name: string;
format: string;
size: number;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() {
const [files, setFiles] = useState<AudioFile[]>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
return parsed.files;
}
}
}
catch (err) {
console.error("Failed to load saved state:", err);
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
return parsed.outputFormat;
}
}
}
catch (err) {
}
return "mp3";
});
const [bitrate, setBitrate] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.bitrate) {
return parsed.bitrate;
}
}
}
catch (err) {
}
return "320k";
});
const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
return parsed.m4aCodec;
}
}
}
catch (err) {
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
}
catch (err) {
console.error("Failed to save state:", err);
}
}, []);
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
useEffect(() => {
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
const hasFlacFiles = files.some((f) => f.format === "flac");
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const handleSelectFiles = async () => {
try {
const selectedFiles = await SelectAudioFiles();
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const handleSelectFolder = async () => {
try {
const selectedFolder = await SelectFolder("");
if (selectedFolder) {
const folderFiles = await ListAudioFilesInDir(selectedFolder);
if (folderFiles && folderFiles.length > 0) {
addFiles(folderFiles.map((f) => f.path));
}
else {
toast.info("No audio files found", {
description: "No FLAC or MP3 files found in the selected folder.",
});
}
}
}
catch (err) {
toast.error("Folder Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select folder",
});
}
};
const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return ext === ".m4a";
});
if (m4aFiles.length > 0) {
toast.error("M4A files not supported", {
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
});
}
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.filter((path) => !prev.some((f) => f.path === path))
.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
return {
path,
name,
format: ext,
size: fileSizes[path] || 0,
status: "pending" as const,
};
});
if (newFiles.length > 0) {
if (paths.length > newFiles.length) {
const skipped = paths.length - newFiles.length;
toast.info("Some files skipped", {
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
});
}
return [...prev, ...newFiles];
}
if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
}
return prev;
});
}, []);
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
addFiles(paths);
}, [addFiles]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path));
};
const clearFiles = () => {
setFiles([]);
};
const handleConvert = async () => {
if (files.length === 0) {
toast.error("No files selected", {
description: "Please add audio files to convert",
});
return;
}
setConverting(true);
try {
const inputPaths = files.map((f) => f.path);
setFiles((prev) => prev.map((f) => {
if (inputPaths.includes(f.path)) {
return { ...f, status: "converting" as const, error: undefined };
}
return f;
}));
const results = await ConvertAudio({
input_files: inputPaths,
output_format: outputFormat,
bitrate: bitrate,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
setFiles((prev) => prev.map((f) => {
const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
if (result) {
return {
...f,
status: result.success ? "success" : "error",
error: result.error,
outputPath: result.output_file,
};
}
return f;
}));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
if (successCount > 0) {
toast.success("Conversion Complete", {
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
}
else if (failCount > 0) {
toast.error("Conversion Failed", {
description: `All ${failCount} file(s) failed to convert`,
});
}
}
catch (err) {
toast.error("Conversion Error", {
description: err instanceof Error ? err.message : "Unknown error",
});
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })));
}
finally {
setConverting(false);
}
};
const getStatusIcon = (status: AudioFile["status"]) => {
switch (status) {
case "converting":
return <Spinner className="h-4 w-4 text-primary"/>;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "error":
return <AlertCircle className="h-4 w-4 text-destructive"/>;
default:
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
}
};
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Audio Converter</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add Files
</Button>
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
<Upload className="h-4 w-4"/>
Add Folder
</Button>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<div className="flex gap-3">
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<Button onClick={handleSelectFolder} size="lg" variant="outline">
<Upload className="h-5 w-5"/>
Select Folder
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
<div className="space-y-2 pb-4 border-b shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
</ToggleGroup>
</div>
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
if (value)
setM4aCodec(value as "aac" | "alac");
}}>
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
setBitrate(value);
}}>
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
</div>
</div>
<div className="flex items-center justify-between shrink-0">
<div className="text-sm text-muted-foreground">
{files.length} file(s) {successCount} converted
</div>
</div>
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon(file.status)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.error && (<p className="truncate text-xs text-destructive">
{file.error}
</p>)}
</div>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
<span className="text-xs uppercase text-muted-foreground">
{file.format}
</span>
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
<X className="h-4 w-4"/>
</Button>)}
</div>))}
</div>
<div className="flex justify-center pt-4 border-t shrink-0">
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
{converting ? (<>
<Spinner className="h-4 w-4"/>
Converting...
</>) : (<>
<WandSparkles className="h-4 w-4"/>
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
</>)}
</Button>
</div>
</div>)}
</div>
</div>);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check, FileDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger";
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function DebugLoggerPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
}
catch (err) {
console.error("Failed to copy logs:", err);
}
};
const handleExportFailed = async () => {
try {
const message = await ExportFailedDownloads();
if (message.startsWith("Successfully")) {
toast.success(message);
}
else if (message !== "Export cancelled") {
toast.info(message);
}
}
catch (error) {
console.error("Failed to export:", error);
toast.error(`Failed to export: ${error}`);
}
};
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
<FileDown className="h-4 w-4"/>
Export Failed
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
<Trash2 className="h-4 w-4"/>
Clear
</Button>
</div>
</div>
<div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
{logs.length === 0 ? (<p className="text-muted-foreground lowercase">no logs yet...</p>) : (logs.map((log, i) => (<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>)))}
</div>
</div>);
}
@@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/>
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4"/>
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>);
}
@@ -0,0 +1,31 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
if (!hasActiveDownloads) {
return null;
}
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-3">
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s
</p>)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
</div>
</Button>
</div>);
}
+274
View File
@@ -0,0 +1,274 @@
import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
isOpen: boolean;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
}));
useEffect(() => {
if (!isOpen)
return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
}
catch (error) {
console.error("Failed to get download queue:", error);
}
};
fetchQueue();
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
}
catch (error) {
console.error("Failed to clear history:", error);
}
};
const handleReset = async () => {
try {
await ClearAllDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
toast.success("Download queue reset");
}
catch (error) {
console.error("Failed to reset queue:", error);
}
};
const handleExportFailed = async () => {
try {
const message = await ExportFailedDownloads();
if (message.startsWith("Successfully")) {
toast.success(message);
}
else if (message !== "Export cancelled") {
toast.info(message);
}
}
catch (error) {
console.error("Failed to export:", error);
toast.error(`Failed to export: ${error}`);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce"/>;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "failed":
return <XCircle className="h-4 w-4 text-red-500"/>;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500"/>;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground"/>;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>);
};
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0)
return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
else {
return `${seconds}s`;
}
};
const [filterStatus, setFilterStatus] = useState<string>("all");
const toggleFilter = (status: string) => {
setFilterStatus(prev => prev === status ? "all" : status);
};
const filteredQueue = queueInfo.queue.filter((item: any) => {
if (filterStatus === "all")
return true;
return item.status === filterStatus;
});
return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
<Trash2 className="h-3 w-3"/>
Clear History
</Button>)}
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
<FileDown className="h-3 w-3"/>
Export Failures
</Button>)}
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/>
</Button>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
<XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p>
</div>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<p>No downloads with status "{filterStatus}"</p>
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
</div>) : (filteredQueue.map((item: any) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>)}
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>)}
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>)}
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>)}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>)}
</div>
</div>
</div>)))}
</div>
</div>
</DialogContent>
</Dialog>);
}
+96
View File
@@ -0,0 +1,96 @@
import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
export interface HistoryItem {
id: string;
url: string;
type: "track" | "album" | "playlist" | "artist";
name: string;
artist: string;
image: string;
timestamp: number;
}
interface FetchHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
}
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0)
return null;
const getTypeLabel = (type: string) => {
switch (type) {
case "track":
return "Track";
case "album":
return "Album";
case "playlist":
return "Playlist";
case "artist":
return "Artist";
default:
return type;
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case "track":
return Music2;
case "album":
return Disc3;
case "playlist":
return ListMusic;
case "artist":
return UserRound;
default:
return null;
}
};
const getTypeBadgeClass = (type: string) => {
switch (type) {
case "track":
return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400";
case "album":
return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400";
case "playlist":
return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400";
case "artist":
return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400";
default:
return "bg-muted text-muted-foreground";
}
};
return (<div className="space-y-2">
<span className="text-sm text-muted-foreground">{history.length === 1 ? "Recent Fetch" : "Recent Fetches"}</span>
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
{history.map((item) => (<div key={item.id} className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible" onClick={() => onSelect(item)}>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
onRemove(item.id);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
<div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No Image
</div>)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
{item.artist}
</p>
{(() => {
const IconComponent = getTypeIcon(item.type);
return (<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded ${getTypeBadgeClass(item.type)}`}>
{IconComponent ? <IconComponent className="h-2.5 w-2.5"/> : null}
{getTypeLabel(item.type)}
</span>);
})()}
</div>
</div>
</div>))}
</div>
</div>);
}
+739
View File
@@ -0,0 +1,739 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
children?: FileNode[];
expanded?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
type TabType = "track" | "lyric" | "cover";
const FORMAT_PRESETS: Record<string, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
const [allFiles, setAllFiles] = useState<FileNode[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>("track");
const [formatPreset, setFormatPreset] = useState<string>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
}
catch { }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat)
return parsed.customFormat;
}
}
catch { }
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
const [renaming, setRenaming] = useState(false);
const [previewOnly, setPreviewOnly] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMetadata, setShowMetadata] = useState(false);
const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
const [lyricsContent, setLyricsContent] = useState("");
const [lyricsFile, setLyricsFile] = useState("");
const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced");
const [copySuccess, setCopySuccess] = useState(false);
const [showCoverPreview, setShowCoverPreview] = useState(false);
const [coverFile, setCoverFile] = useState("");
const [coverData, setCoverData] = useState("");
const [showManualRename, setShowManualRename] = useState(false);
const [manualRenameFile, setManualRenameFile] = useState("");
const [manualRenameName, setManualRenameName] = useState("");
const [manualRenaming, setManualRenaming] = useState(false);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
}
catch { }
}, [formatPreset, customFormat]);
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
return nodes
.map((node) => {
if (node.is_dir && node.children) {
const filteredChildren = filterFilesByType(node.children, type);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
const ext = node.name.toLowerCase();
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")))
return node;
if (type === "lyric" && ext.endsWith(".lrc"))
return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png")))
return node;
return null;
})
.filter((node): node is FileNode => node !== null);
};
const loadFiles = useCallback(async () => {
if (!rootPath)
return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
if (!result || !Array.isArray(result)) {
setAllFiles([]);
setSelectedFiles(new Set());
return;
}
setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
setAllFiles([]);
setSelectedFiles(new Set());
}
finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath)
loadFiles();
}, [rootPath, loadFiles]);
const filteredFiles = filterFilesByType(allFiles, activeTab);
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir)
result.push(node);
if (node.children)
result.push(...getAllFilesFlat(node.children));
}
return result;
};
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path)
setRootPath(path);
}
catch (err) {
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
setAllFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path)
return { ...node, expanded: !node.expanded };
if (node.children)
return { ...node, children: toggleNodeExpand(node.children, path) };
return node;
});
};
const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path))
newSet.delete(path);
else
newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected)
folderFiles.forEach((f) => newSet.delete(f.path));
else
folderFiles.forEach((f) => newSet.add(f.path));
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllFilesFlat([node]);
if (folderFiles.length === 0)
return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0)
return false;
if (selectedCount === folderFiles.length)
return true;
return "indeterminate";
};
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
const deselectAll = () => setSelectedFiles(new Set());
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) {
toast.error("No files selected");
return;
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
}
catch (err) {
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
}
catch (err) {
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
}
finally {
setLoadingMetadata(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setLyricsFile(filePath);
setLyricsTab("synced");
try {
const content = await ReadTextFile(filePath);
setLyricsContent(content);
setShowLyricsPreview(true);
}
catch (err) {
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(filePath);
try {
const data = await ReadImageAsBase64(filePath);
setCoverData(data);
setShowCoverPreview(true);
}
catch (err) {
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const getPlainLyrics = (content: string) => {
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
};
const formatTimestamp = (timestamp: string): string => {
const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);
if (!match)
return timestamp;
const minutes = parseInt(match[1], 10);
const seconds = match[2];
return `${minutes}:${seconds}`;
};
const renderSyncedLyrics = (content: string) => {
if (!content)
return <div className="text-sm text-muted-foreground">No lyrics content</div>;
const lines = content.split('\n');
return lines.map((line, index) => {
if (line.match(/^\[(ti|ar|al|by|length|offset):/i))
return null;
const match = line.match(/^(\[[\d:.]+\])(.*)$/);
if (match) {
const timestamp = match[1];
const text = match[2].trim();
if (!text)
return null;
return (<div key={index} className="flex items-center gap-2 py-1">
<Badge variant="secondary" className="font-mono text-xs shrink-0">
{formatTimestamp(timestamp)}
</Badge>
<span className="text-sm">{text}</span>
</div>);
}
if (!line.trim())
return null;
return (<div key={index} className="py-1">
<span className="text-sm">{line}</span>
</div>);
}).filter(item => item !== null);
};
const handleCopyLyrics = async () => {
try {
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
await navigator.clipboard.writeText(textToCopy);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 500);
}
catch {
toast.error("Failed to copy lyrics");
}
};
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileName = filePath.split(/[/\\]/).pop() || "";
const nameWithoutExt = fileName.replace(/\.[^.]+$/, "");
setManualRenameFile(filePath);
setManualRenameName(nameWithoutExt);
setShowManualRename(true);
};
const handleConfirmManualRename = async () => {
if (!manualRenameFile || !manualRenameName.trim())
return;
setManualRenaming(true);
try {
await RenameFileTo(manualRenameFile, manualRenameName.trim());
toast.success("File renamed successfully");
setShowManualRename(false);
loadFiles();
}
catch (err) {
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setManualRenaming(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0)
return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0)
toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else
toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
}
catch (err) {
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setRenaming(false);
}
};
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
{node.is_dir ? (<>
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
if (el)
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<>
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
</>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
</div>));
};
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
</div>));
};
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
</div>));
};
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
<div className="flex items-center gap-2 shrink-0">
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
<Button onClick={handleSelectFolder}>
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
Refresh
</Button>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
<FileMusic className="h-4 w-4"/>
Track ({allAudioFiles.length})
</Button>
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
<FileText className="h-4 w-4"/>
Lyric ({allLyricFiles.length})
</Button>
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
<Image className="h-4 w-4"/>
Cover ({allCoverFiles.length})
</Button>
</div>
{activeTab === "track" && (<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
<RotateCcw className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac</span>
</p>
</div>)}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
<Eye className="h-4 w-4"/>
Preview
</Button>
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
<Pencil className="h-4 w-4"/>
Rename
</Button>
</div>
</div>)}
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
renderCoverTree(filteredFiles))}
</div>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={resetToDefault}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename Preview</DialogTitle>
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>}
</div>
</div>))}
</div>
<DialogFooter>
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
</Button>
</>)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Lyrics Preview</DialogTitle>
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 border-b pb-2">
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
</div>
<div className="flex-1 overflow-y-auto py-4">
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
{renderSyncedLyrics(lyricsContent)}
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
<DialogTitle>Cover Preview</DialogTitle>
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center p-4">
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
</div>
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
<DialogContent className="max-w-2xl [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="newName" className="text-sm">New Name</Label>
<div className="flex items-center gap-2 mt-2">
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
if (e.key === "Enter" && !manualRenaming)
handleConfirmManualRename();
}}/>
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+42
View File
@@ -0,0 +1,42 @@
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps {
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
SpotiFLAC
</h1>
<div className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" asChild>
<button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>)}
</Tooltip>
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>)}
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p>
</div>
</div>);
}
+615
View File
@@ -0,0 +1,615 @@
import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface DownloadHistoryItem {
id: string;
spotify_id: string;
title: string;
artists: string;
album: string;
duration_str: string;
cover_url: string;
quality: string;
format: string;
path: string;
timestamp: number;
}
interface FetchHistoryItem {
id: string;
url: string;
type: string;
name: string;
info: string;
image: string;
data: string;
timestamp: number;
}
interface HistoryPageProps {
onHistorySelect?: (cachedData: string) => void;
}
export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [activeTab, setActiveTab] = useState("downloads");
const [downloadHistory, setDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [filteredDownloadHistory, setFilteredDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false);
const [downloadSearchQuery, setDownloadSearchQuery] = useState("");
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false);
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 50;
const fetchDownloadHistory = async () => {
try {
const items = await GetDownloadHistory();
setDownloadHistory(items || []);
}
catch (err) {
console.error("Failed to fetch download history:", err);
}
};
const fetchFetchHistory = async () => {
try {
const items = await GetFetchHistory();
setFetchHistory(items || []);
}
catch (err) {
console.error("Failed to fetch fetch history:", err);
}
};
useEffect(() => {
if (activeTab === "downloads") {
fetchDownloadHistory();
const interval = setInterval(fetchDownloadHistory, 5000);
return () => clearInterval(interval);
}
else {
fetchFetchHistory();
const interval = setInterval(fetchFetchHistory, 5000);
return () => clearInterval(interval);
}
}, [activeTab]);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
useEffect(() => {
let result = [...downloadHistory];
if (downloadSearchQuery) {
const query = downloadSearchQuery.toLowerCase();
result = result.filter(item => item.title.toLowerCase().includes(query) ||
item.artists.toLowerCase().includes(query) ||
item.album.toLowerCase().includes(query));
}
const parseDuration = (str: string) => {
const parts = str.split(':').map(Number);
if (parts.length === 2)
return parts[0] * 60 + parts[1];
if (parts.length === 3)
return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
result.sort((a, b) => {
switch (downloadSortBy) {
case "default":
case "date_desc": return b.timestamp - a.timestamp;
case "date_asc": return a.timestamp - b.timestamp;
case "title_asc": return a.title.localeCompare(b.title);
case "title_desc": return b.title.localeCompare(a.title);
case "artist_asc": return a.artists.localeCompare(b.artists);
case "artist_desc": return b.artists.localeCompare(a.artists);
case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str);
case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str);
default: return 0;
}
});
setFilteredDownloadHistory(result);
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
useEffect(() => {
setDownloadCurrentPage(1);
}, [downloadSearchQuery, downloadSortBy]);
useEffect(() => {
let result = [...fetchHistory];
if (activeFetchTab !== "all") {
result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase());
}
if (fetchSearchQuery) {
const query = fetchSearchQuery.toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(query) ||
item.info.toLowerCase().includes(query));
}
result.sort((a, b) => b.timestamp - a.timestamp);
setFilteredFetchHistory(result);
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
useEffect(() => {
setFetchCurrentPage(1);
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = 0.5;
audio.onended = () => setPlayingPreviewId(null);
audio.play();
setPlayingPreviewId(id);
}
}
catch (e) {
console.error("Failed to play preview:", e);
}
};
const handleClearDownloadHistory = async () => {
await ClearDownloadHistory();
fetchDownloadHistory();
setShowClearDownloadConfirm(false);
};
const handleDeleteDownloadItem = async (id: string) => {
await DeleteDownloadHistoryItem(id);
setDownloadHistory(prev => prev.filter(item => item.id !== id));
};
const handleClearFetchHistory = async () => {
await ClearFetchHistoryByType(activeFetchTab);
fetchFetchHistory();
setShowClearFetchConfirm(false);
};
const handleDeleteFetchItem = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await DeleteFetchHistoryItem(id);
setFetchHistory(prev => prev.filter(item => item.id !== id));
};
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10)
return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++)
pages.push(i);
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++)
pages.push(i);
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const renderDownloadHistory = () => {
const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
{downloadHistory.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear All
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
<History className="h-10 w-10 opacity-40"/>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No download history</p>
<p className="text-sm">Your downloaded tracks will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
{item.duration_str}
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
<ExternalLink className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open in Spotify</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={() => handleDeleteDownloadItem(item.id)}>
<Trash2 className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (downloadCurrentPage > 1)
setDownloadCurrentPage(downloadCurrentPage - 1);
}} className={downloadCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(downloadCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setDownloadCurrentPage(page as number);
}} isActive={downloadCurrentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (downloadCurrentPage < totalPages)
setDownloadCurrentPage(downloadCurrentPage + 1);
}} className={downloadCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
};
const renderFetchHistory = () => {
const totalPages = Math.ceil(filteredFetchHistory.length / ITEMS_PER_PAGE);
const startIndex = (fetchCurrentPage - 1) * ITEMS_PER_PAGE;
const paginated = filteredFetchHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Fetches</h2>
{fetchHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
{fetchHistory.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearFetchConfirm(true)} disabled={fetchHistory.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear All
</Button>
</div>
<div className="flex flex-col gap-4">
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeFetchTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("track")} className="rounded-b-none">
<Music2 className="h-4 w-4"/>
Tracks
</Button>
<Button variant={activeFetchTab === "album" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("album")} className="rounded-b-none">
<Disc3 className="h-4 w-4"/>
Albums
</Button>
<Button variant={activeFetchTab === "playlist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("playlist")} className="rounded-b-none">
<ListMusic className="h-4 w-4"/>
Playlists
</Button>
<Button variant={activeFetchTab === "artist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("artist")} className="rounded-b-none">
<UserRound className="h-4 w-4"/>
Artists
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search fetch history..." value={fetchSearchQuery} onChange={(e) => setFetchSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
</div>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground gap-3">
<Database className="h-10 w-10 opacity-40"/>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No fetch history</p>
<p className="text-sm">Fetched metadata will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-1/3">
{activeFetchTab === 'artist' ? 'Name' : 'Title'}
</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase">Details</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-40 text-xs uppercase text-nowrap">Fetched At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded shrink-0 bg-secondary overflow-hidden">
{item.image ? (<img src={item.image} alt={item.name} className="h-full w-full object-cover"/>) : (<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground font-medium bg-muted">
{item.type.slice(0, 2).toUpperCase()}
</div>)}
</div>
<span className="font-medium text-sm truncate">{item.name}</span>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.info}</div>
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => onHistorySelect?.(item.data)}>
<CloudUpload className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Load</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={(e) => handleDeleteFetchItem(item.id, e)}>
<Trash2 className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (fetchCurrentPage > 1)
setFetchCurrentPage(fetchCurrentPage - 1);
}} className={fetchCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(fetchCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setFetchCurrentPage(page as number);
}} isActive={fetchCurrentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (fetchCurrentPage < totalPages)
setFetchCurrentPage(fetchCurrentPage + 1);
}} className={fetchCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
};
return (<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">History</h1>
</div>
<div className="border-b">
<div className="flex gap-6">
<button onClick={() => setActiveTab("downloads")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "downloads" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Downloads
</button>
<button onClick={() => setActiveTab("fetches")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "fetches" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Fetches
</button>
</div>
</div>
{activeTab === "downloads" && (<div className="mt-6">
{renderDownloadHistory()}
</div>)}
{activeTab === "fetches" && (<div className="mt-6">
{renderFetchHistory()}
</div>)}
<Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear Download History?</DialogTitle>
<DialogDescription>
This will remove all entries from your download history. This action cannot be undone.
Note: The actual downloaded files will NOT be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
<DialogDescription>
This will remove all {activeFetchTab} entries from your fetch history cache.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearFetchHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+18
View File
@@ -0,0 +1,18 @@
export const TidalIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>);
export const QobuzIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>);
export const AmazonIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>);
+234
View File
@@ -0,0 +1,234 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import { getSettings } from "@/lib/settings";
import { downloadCover } from "@/lib/api";
import { useState } from "react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { parseTemplate, type TemplateData } from "@/lib/settings";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
owner: {
name: string;
display_name: string;
images: string;
};
tracks: {
total: number;
};
followers: {
total: number;
};
cover?: string;
description?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
const settings = getSettings();
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
const handleDownloadPlaylistCover = async () => {
if (!playlistInfo.cover)
return;
setDownloadingPlaylistCover(true);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const playlistName = playlistInfo.owner.name;
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: "",
album: "",
album_artist: "",
title: playlistName.replace(/\//g, placeholder),
playlist: playlistName.replace(/\//g, placeholder),
};
if (settings.createPlaylistFolder && playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
}
}
}
const response = await downloadCover({
cover_url: playlistInfo.cover,
track_name: playlistName,
artist_name: "",
album_name: "",
album_artist: "",
release_date: "",
output_dir: outputDir,
filename_format: "title",
track_number: false,
position: 0,
disc_number: 0,
});
if (response.success) {
if (response.already_exists)
toast.info("Cover already exists");
else
toast.success("Playlist cover downloaded");
}
else {
toast.error(response.error || "Failed to download cover");
}
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download cover");
}
finally {
setDownloadingPlaylistCover(false);
}
};
return (<div className="space-y-6">
<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlistInfo.cover && (<div className="relative group shrink-0 w-48 h-48">
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent><p>Download Playlist Cover</p></TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
{playlistInfo.owner.images && (<img src={playlistInfo.owner.images} alt={playlistInfo.owner.display_name} className="w-5 h-5 rounded-full object-cover"/>)}
<span className="font-medium">{playlistInfo.owner.display_name}</span>
</div>
<span></span>
<span>
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"}</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={playlistInfo.owner.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>);
}
+40
View File
@@ -0,0 +1,40 @@
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps {
searchQuery: string;
sortBy: string;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
}
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
return (<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onSearchChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</div>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[200px] gap-1.5">
<ArrowUpDown className="h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
<SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
<SelectItem value="failed">Failed Downloads</SelectItem>
</SelectContent>
</Select>
</div>);
}
+803
View File
@@ -0,0 +1,803 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputWithContext } from "@/components/ui/input-with-context";
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
import { useTypingEffect } from "@/hooks/useTypingEffect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const FETCH_PLACEHOLDERS = [
"https://open.spotify.com/track/...",
"https://open.spotify.com/album/...",
"https://open.spotify.com/playlist/...",
"https://open.spotify.com/artist/...",
];
const SEARCH_PLACEHOLDERS = [
"Golden",
"Taylor Swift",
"The Weeknd",
"Starboy",
"Joji",
"Die For You",
];
const REGIONS = [
"AD",
"AE",
"AG",
"AL",
"AM",
"AO",
"AR",
"AT",
"AU",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BN",
"BO",
"BR",
"BS",
"BT",
"BW",
"BZ",
"CA",
"CD",
"CG",
"CH",
"CI",
"CL",
"CM",
"CO",
"CR",
"CV",
"CW",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"ES",
"ET",
"FI",
"FJ",
"FM",
"FR",
"GA",
"GB",
"GD",
"GE",
"GH",
"GM",
"GN",
"GQ",
"GR",
"GT",
"GW",
"GY",
"HK",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IN",
"IQ",
"IS",
"IT",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KR",
"KW",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MG",
"MH",
"MK",
"ML",
"MN",
"MO",
"MR",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NE",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NZ",
"OM",
"PA",
"PE",
"PG",
"PH",
"PK",
"PL",
"PS",
"PT",
"PW",
"PY",
"QA",
"RO",
"RS",
"RW",
"SA",
"SB",
"SC",
"SE",
"SG",
"SI",
"SK",
"SL",
"SM",
"SN",
"SR",
"ST",
"SV",
"SZ",
"TD",
"TG",
"TH",
"TJ",
"TL",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"US",
"UY",
"UZ",
"VC",
"VE",
"VN",
"VU",
"WS",
"XK",
"ZA",
"ZM",
"ZW",
];
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
const getRegionName = (code: string) => {
try {
if (code === "XK")
return "Kosovo";
return regionNames.of(code) || code;
}
catch (e) {
return code;
}
};
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
onFetchUrl: (url: string) => Promise<void>;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
region: string;
onRegionChange: (region: string) => void;
}
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [resultFilter, setResultFilter] = useState("");
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
tracks: "default",
albums: "default",
artists: "default",
playlists: "default",
});
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
tracks: false,
albums: false,
artists: false,
playlists: false,
});
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
const [invalidUrl, setInvalidUrl] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
const placeholderText = useTypingEffect(placeholders);
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
}
catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed)
return;
setRecentSearches((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
const removeRecentSearch = (query: string) => {
setRecentSearches((prev) => {
const updated = prev.filter((s) => s !== query);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({
query: searchQuery,
limit: SEARCH_LIMIT,
});
setSearchResults(results);
setResultFilter("");
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
setHasMore({
tracks: results.tracks.length === SEARCH_LIMIT,
albums: results.albums.length === SEARCH_LIMIT,
artists: results.artists.length === SEARCH_LIMIT,
playlists: results.playlists.length === SEARCH_LIMIT,
});
if (results.tracks.length > 0)
setActiveTab("tracks");
else if (results.albums.length > 0)
setActiveTab("albums");
else if (results.artists.length > 0)
setActiveTab("artists");
else if (results.playlists.length > 0)
setActiveTab("playlists");
}
catch (error) {
console.error("Search failed:", error);
setSearchResults(null);
}
finally {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
if (!searchResults || !lastSearchedQuery || isLoadingMore)
return;
const typeMap: Record<ResultTab, string> = {
tracks: "track",
albums: "album",
artists: "artist",
playlists: "playlist",
};
const currentCount = getTabCount(activeTab);
setIsLoadingMore(true);
try {
const moreResults = await SearchSpotifyByType({
query: lastSearchedQuery,
search_type: typeMap[activeTab],
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
if (!prev)
return prev;
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks"
? [...prev.tracks, ...moreResults]
: prev.tracks,
albums: activeTab === "albums"
? [...prev.albums, ...moreResults]
: prev.albums,
artists: activeTab === "artists"
? [...prev.artists, ...moreResults]
: prev.artists,
playlists: activeTab === "playlists"
? [...prev.playlists, ...moreResults]
: prev.playlists,
});
return updated;
});
}
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
}
catch (error) {
console.error("Load more failed:", error);
}
finally {
setIsLoadingMore(false);
}
};
const isSpotifyUrl = (text: string) => {
const trimmed = text.trim();
if (!trimmed)
return true;
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
if (!isUrl)
return true;
return (trimmed.includes("spotify.com") ||
trimmed.includes("spotify.link") ||
trimmed.startsWith("spotify:"));
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchMode)
return;
const pastedText = e.clipboardData.getData("text");
if (pastedText && !isSpotifyUrl(pastedText)) {
e.preventDefault();
setInvalidUrl(pastedText);
setShowInvalidUrlDialog(true);
}
};
const handleFetchWithValidation = () => {
if (!isSpotifyUrl(url)) {
setInvalidUrl(url);
setShowInvalidUrlDialog(true);
return;
}
onFetch();
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults &&
(searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults)
return 0;
switch (tab) {
case "tracks":
return searchResults.tracks.length;
case "albums":
return searchResults.albums.length;
case "artists":
return searchResults.artists.length;
case "playlists":
return searchResults.playlists.length;
}
};
const sortedResults = useMemo(() => {
if (!searchResults)
return { tracks: [], albums: [], artists: [], playlists: [] };
const filterStr = resultFilter.toLowerCase();
let tracks = [...searchResults.tracks];
if (filterStr) {
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
}
const tSort = sortOrders.tracks;
if (tSort !== 'default') {
tracks.sort((a, b) => {
if (tSort === 'title-asc')
return (a.name || '').localeCompare(b.name || '');
if (tSort === 'title-desc')
return (b.name || '').localeCompare(a.name || '');
if (tSort === 'artist-asc')
return (a.artists || '').localeCompare(b.artists || '');
if (tSort === 'artist-desc')
return (b.artists || '').localeCompare(a.artists || '');
if (tSort === 'duration-desc')
return (b.duration_ms || 0) - (a.duration_ms || 0);
if (tSort === 'duration-asc')
return (a.duration_ms || 0) - (b.duration_ms || 0);
return 0;
});
}
let albums = [...searchResults.albums];
if (filterStr) {
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
}
const alSort = sortOrders.albums;
if (alSort !== 'default') {
albums.sort((a, b) => {
if (alSort === 'title-asc')
return (a.name || '').localeCompare(b.name || '');
if (alSort === 'title-desc')
return (b.name || '').localeCompare(a.name || '');
if (alSort === 'artist-asc')
return (a.artists || '').localeCompare(b.artists || '');
if (alSort === 'artist-desc')
return (b.artists || '').localeCompare(a.artists || '');
if (alSort === 'year-desc')
return (b.release_date || '').localeCompare(a.release_date || '');
if (alSort === 'year-asc')
return (a.release_date || '').localeCompare(b.release_date || '');
return 0;
});
}
let artists = [...searchResults.artists];
if (filterStr) {
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
}
const arSort = sortOrders.artists;
if (arSort !== 'default') {
artists.sort((a, b) => {
if (arSort === 'name-asc')
return (a.name || '').localeCompare(b.name || '');
if (arSort === 'name-desc')
return (b.name || '').localeCompare(a.name || '');
return 0;
});
}
let playlists = [...searchResults.playlists];
if (filterStr) {
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
}
const pSort = sortOrders.playlists;
if (pSort !== 'default') {
playlists.sort((a, b) => {
if (pSort === 'title-asc')
return (a.name || '').localeCompare(b.name || '');
if (pSort === 'title-desc')
return (b.name || '').localeCompare(a.name || '');
if (pSort === 'owner-asc')
return (a.owner || '').localeCompare(b.owner || '');
if (pSort === 'owner-desc')
return (b.owner || '').localeCompare(a.owner || '');
return 0;
});
}
return { tracks, albums, artists, playlists };
}, [searchResults, sortOrders, resultFilter]);
const tabs: {
key: ResultTab;
label: string;
}[] = [
{ key: "tracks", label: "Tracks" },
{ key: "albums", label: "Albums" },
{ key: "artists", label: "Artists" },
{ key: "playlists", label: "Playlists" },
];
return (<div className="space-y-4">
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
{searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
</TooltipContent>
</Tooltip>
<div className="relative flex-1">
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/>
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
setResultFilter("");
}}>
<XCircle className="h-4 w-4"/>
</button>)}
</>)}
</div>
{!searchMode && (<>
<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
</span>
</SelectItem>))}
</SelectContent>
</Select>
<Button onClick={handleFetchWithValidation} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>)}
</Button>
</>)}
</div>
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<span>{query}</span>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
removeRecentSearch(query);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
</div>)}
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>)}
{!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b mb-4">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0)
return null;
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground")}>
{tab.label} ({count})
</button>);
})}
</div>
<div className="flex gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input placeholder={`Search ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
{resultFilter && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => setResultFilter("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</div>
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
<SelectTrigger className="w-[170px] bg-background gap-1.5">
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
{activeTab === 'tracks' && (<>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-desc">Duration (Longest)</SelectItem>
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
</>)}
{activeTab === 'albums' && (<>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="year-desc">Year (Newest)</SelectItem>
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
</>)}
{activeTab === 'artists' && (<>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
</>)}
{activeTab === 'playlists' && (<>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
</>)}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
{activeTab === "tracks" &&
sortedResults.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<p className="font-medium truncate">{track.name}</p>
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
E
</span>)}
</div>
<p className="text-sm text-muted-foreground truncate">
{track.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>))}
{activeTab === "albums" &&
sortedResults.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">
{album.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.release_date || ""}
</span>
</button>))}
{activeTab === "artists" &&
sortedResults.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>))}
{activeTab === "playlists" &&
sortedResults.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner || ""}
</p>
</div>
</button>))}
</div>
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<>
<Spinner />
Loading...
</>) : (<>
<ChevronDown className="h-4 w-4"/>
Load More
</>)}
</Button>
</div>)}
</>)}
</div>)}
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Invalid URL</DialogTitle>
<DialogDescription>
Only Spotify links are allowed in Fetch mode.
</DialogDescription>
</DialogHeader>
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
{invalidUrl}
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Cancel
</Button>
<Button onClick={() => {
onSearchModeChange(true);
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Switch to Search
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+683
View File
@@ -0,0 +1,683 @@
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { ApiStatusTab } from "./ApiStatusTab";
const TidalIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>);
const QobuzIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>);
const AmazonIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>);
interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void;
}
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
const [showResetConfirm, setShowResetConfirm] = useState(false);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains("dark"));
});
}, []);
useEffect(() => {
if (onResetRequest) {
onResetRequest(resetToSaved);
}
}, [onResetRequest, resetToSaved]);
useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
};
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={async () => { try {
await OpenConfigFolder();
}
catch (e) {
toast.error(`Failed to open config folder: ${e}`);
} }} className="gap-1.5">
<FolderLock className="h-4 w-4"/>
Open Config Folder
</Button>
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
<MonitorCog className="h-4 w-4"/>
General
</Button>
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
<FolderCog className="h-4 w-4"/>
File Management
</Button>
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
<Router className="h-4 w-4"/>
API Status
</Button>
</div>
<div className="flex-1 overflow-y-auto pt-4">
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme"/>
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary,
}}/>
{theme.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font"/>
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>
{font.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3 pt-2">
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
sfxEnabled: checked,
}))}/>
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm font-normal">
Sound Effects
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="flex gap-2 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev,
downloader: value,
}))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-fit min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
</SelectContent>
</Select>
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">
24-bit/48kHz
</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz
</div>)}
</div>
{((tempSettings.downloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "27") ||
(tempSettings.downloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<div className="flex items-center gap-3">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowFallback: checked,
}))}/>
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
Allow Quality Fallback (16-bit)
</Label>
</div>
</div>)}
</div>
<div className="border-t pt-6"/>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre
</Label>
</div>)}
</div>
</div>
</div>)}
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings((prev) => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom"
? prev.folderTemplate || preset.template
: preset.template,
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
folderTemplate: e.target.value,
}))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview:{" "}
<span className="font-mono">
{tempSettings.folderTemplate
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
.replace(/\{album\}/g, "Black Panther")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{title\}/g, "All The Stars")
.replace(/\{track\}/g, "01")
.replace(/\{disc\}/g, "1")
.replace(/\{year\}/g, "2018")
.replace(/\{date\}/g, "2018-02-09")}
/
</span>
</p>)}
</div>
<div className="flex items-center gap-3">
<Switch id="create-playlist-folder" checked={tempSettings.createPlaylistFolder} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
createPlaylistFolder: checked,
}))}/>
<Label htmlFor="create-playlist-folder" className="text-sm cursor-pointer font-normal">
Playlist Folder
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="create-m3u8-file" checked={tempSettings.createM3u8File} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
createM3u8File: checked,
}))}/>
<Label htmlFor="create-m3u8-file" className="text-sm cursor-pointer font-normal">
Create M3U8 Playlist File
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings((prev) => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom"
? prev.filenameTemplate || preset.template
: preset.template,
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
<div className="space-y-2 pt-2">
<Label className="text-sm">Separator</Label>
<div className="flex gap-2">
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
...prev,
separator: value,
}))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comma">Comma (,)</SelectItem>
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview:{" "}
<span className="font-mono">
{tempSettings.filenameTemplate
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{album\}/g, "Black Panther")
.replace(/\{title\}/g, "All The Stars")
.replace(/\{track\}/g, "01")
.replace(/\{disc\}/g, "1")
.replace(/\{year\}/g, "2018")
.replace(/\{date\}/g, "2018-02-09")}
.flac
</span>
</p>)}
</div>
</div>)}
{activeTab === "api" && (<ApiStatusTab />)}
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom
configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>
Cancel
</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+133
View File
@@ -0,0 +1,133 @@
import { HomeIcon } from "@/components/ui/home";
import { HistoryIcon } from "@/components/ui/history-icon";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon } from "@/components/ui/file-music";
import { FilePenIcon } from "@/components/ui/file-pen";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks-icon";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
<HistoryIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>History</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<SettingsIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<TerminalIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Debug Logs</p>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip delayDuration={0}>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<BlocksIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
<TooltipContent side="right">
<p>Tools</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3">
<ActivityIcon size={16}/>
<span>Audio Quality Analyzer</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3">
<FileMusicIcon size={16}/>
<span>Audio Converter</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3">
<FilePenIcon size={16}/>
<span>File Manager</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/268")}>
<GithubIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bugs or Request Features</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
}
@@ -0,0 +1,193 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas)
return;
const ctx = canvas.getContext("2d");
if (!ctx)
return;
const width = canvas.width;
const height = canvas.height;
const marginLeft = 70;
const marginRight = 70;
const marginTop = 30;
const marginBottom = 65;
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
}
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0)
return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB)
maxDB = db;
if (db < minDB && db > -200)
minDB = db;
});
});
minDB = Math.max(minDB, maxDB - 90);
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
}
else if (intensity < 0.18) {
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
}
else if (intensity < 0.28) {
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
}
else if (intensity < 0.40) {
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
}
else if (intensity < 0.52) {
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
}
else if (intensity < 0.65) {
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
}
else if (intensity < 0.78) {
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
}
else if (intensity < 0.90) {
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
}
else {
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.fillText(label, x - 8, yPos);
}
});
ctx.fillText("0", x - 8, y + height);
ctx.textAlign = "center";
ctx.textBaseline = "top";
const timeStep = getTimeStep(duration);
for (let t = 0; t <= duration; t += timeStep) {
const xPos = x + (t / duration) * width;
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
}
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
ctx.textAlign = "right";
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
};
const generateFreqLabels = (nyquistFreq: number): number[] => {
if (nyquistFreq <= 24000) {
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
}
else if (nyquistFreq <= 48000) {
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
}
else if (nyquistFreq <= 96000) {
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
}
else {
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
}
};
const getTimeStep = (duration: number): number => {
if (duration <= 60)
return 15;
if (duration <= 120)
return 30;
if (duration <= 300)
return 30;
if (duration <= 600)
return 60;
return 60;
};
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height);
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(x, y + i, width, 1);
}
ctx.strokeStyle = "#666666";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = "#FFFFFF";
ctx.font = "11px Arial";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("High", x + width + 5, y + 10);
ctx.fillText("Low", x + width + 5, y + height - 10);
};
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
</div>);
}
+82
View File
@@ -0,0 +1,82 @@
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getSettings, updateSettings } from "@/lib/settings";
import { useState, useEffect } from "react";
export function TitleBar() {
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
useEffect(() => {
const settings = getSettings();
if (settings) {
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
}
const handleSettingsUpdate = (event: any) => {
const updatedSettings = event.detail;
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
}
};
window.addEventListener('settingsUpdated', handleSettingsUpdate);
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
}, []);
const handleSpotFetchAPIToggle = () => {
const newValue = !useSpotFetchAPI;
setUseSpotFetchAPI(newValue);
updateSettings({ useSpotFetchAPI: newValue });
};
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (<>
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
<SlidersHorizontal className="w-3.5 h-3.5"/>
</MenubarTrigger>
<MenubarContent align="end" className="min-w-[200px]">
<div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<MenubarSeparator />
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
<span>Use SpotFetch API</span>
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
<Minus className="w-3.5 h-3.5"/>
</button>
<button onClick={handleMaximize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Maximize">
<Maximize className="w-3.5 h-3.5"/>
</button>
<button onClick={handleClose} className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Close">
<X className="w-3.5 h-3.5"/>
</button>
</div>
</>);
}
+158
View File
@@ -0,0 +1,158 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps {
track: TrackMetadata & {
album_name: string;
release_date: string;
};
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
onBack?: () => void;
}
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string) => {
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
<div className="shrink-0">
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
{formatDuration(track.duration_ms)}
</div>
</div>)}
</div>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
<p className="font-medium">{formatPlays(track.plays)}</p>
</div>)}
</div>
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div>
</div>
{track.spotify_id && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
</Button>
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Separate Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Separate Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>)}
</div>
</div>
</CardContent>
</Card>);
}
+378
View File
@@ -0,0 +1,378 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
let filteredTracks = tracks.filter((track) => {
if (!searchQuery)
return true;
const query = searchQuery.toLowerCase();
return (track.name.toLowerCase().includes(query) ||
track.artists.toLowerCase().includes(query) ||
track.album_name.toLowerCase().includes(query));
});
if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
}
else if (sortBy === "title-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
}
else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
}
else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
}
else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
}
else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
}
else if (sortBy === "plays-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return aPlays - bPlays;
});
}
else if (sortBy === "plays-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return bPlays - aPlays;
});
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
}
else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
else if (sortBy === "failed") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++) {
pages.push(i);
}
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++) {
pages.push(i);
}
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
const allSelected = tracksWithId.length > 0 &&
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string | undefined) => {
if (!plays)
return "";
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<div className="space-y-4">
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
#
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
Plays
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions
</th>
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5">
<span>{startIndex + index + 1}</span>
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
? "text-green-500"
: track.status === "DOWN"
? "text-red-500"
: track.status === "NEW"
? "text-blue-500"
: ""}`}>
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
</span>)}
</div>
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name}
</span>) : (<span className="font-medium">{track.name}</span>)}
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
const artistNames = track.artists.split(", ").map(name => name.trim());
return artistNames.map((name, i) => {
const artistData = track.artists_data![i];
const hasArtistData = artistData && artistData.id && artistData.external_urls;
return (<span key={artistData?.id || i}>
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artistData.id,
name: name,
external_urls: artistData.external_urls,
})}>
{name}
</span>) : (name)}
{i < artistNames.length - 1 && ", "}
</span>);
});
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: track.artist_id!,
name: track.artists,
external_urls: track.artist_url!,
})}>
{track.artists}
</span>) : (track.artists)}
</span>
</div>
</div>
</td>
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</span>) : (track.album_name)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
</td>
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
{track.plays ? formatPlays(track.plays) : ""}
</td>
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent>
</Tooltip>)}
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Separate Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
}} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Separate Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
</div>
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
onPageChange(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
onPageChange(page);
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
onPageChange(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
pathOffset: [1, 0],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
@@ -0,0 +1,61 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "span";
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
}
export { Badge, badgeVariants };
@@ -0,0 +1,53 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn("flex items-center justify-center", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
<motion.path animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
</svg>
</div>);
});
BlocksIcon.displayName = "BlocksIcon";
export { BlocksIcon };
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "h-9 w-9 p-0",
"icon-sm": "h-8 w-8 p-0",
"icon-lg": "h-10 w-10 p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
}
export { Button, buttonVariants };
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
+13
View File
@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
<CheckIcon className="size-3.5"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>);
}
export { Checkbox };
+67
View File
@@ -0,0 +1,67 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
opacity: 1,
},
animate: (custom: number) => ({
y: -3,
opacity: [0, 1, 0],
transition: {
repeat: Infinity,
duration: 1.5,
ease: 'easeInOut',
delay: 0.2 * custom,
},
}),
};
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
<motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
<motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
<motion.path d="M6 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0}/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
</svg>
</div>);
});
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
@@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
}
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
}
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger>);
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
</ContextMenuPrimitive.Portal>);
}
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
}
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>);
}
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>);
}
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
}
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
}
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
{children}
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>)}
</DialogPrimitive.Content>
</DialogPortal>);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
@@ -0,0 +1,76 @@
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
}
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
</DropdownMenuPrimitive.Portal>);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
}
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
}
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>);
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
}
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>);
}
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
}
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
{children}
<ChevronRightIcon className="ml-auto size-4"/>
</DropdownMenuPrimitive.SubTrigger>);
}
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
}
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
+64
View File
@@ -0,0 +1,64 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FileMusicIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileMusicIcon.displayName = 'FileMusicIcon';
export { FileMusicIcon };
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FilePenIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FilePenIcon.displayName = 'FilePenIcon';
export { FilePenIcon };
+102
View File
@@ -0,0 +1,102 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
},
stopAnimation: () => {
bodyControls.start("normal");
tailControls.start("normal");
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
bodyControls.start("normal");
tailControls.start("normal");
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
</svg>
</div>);
});
GithubIcon.displayName = "GithubIcon";
export { GithubIcon };
@@ -0,0 +1,97 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface HistoryIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HistoryIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ARROW_TRANSITION: Transition = {
type: "spring",
stiffness: 250,
damping: 25,
};
const ARROW_VARIANTS: Variants = {
normal: {
rotate: "0deg",
},
animate: {
rotate: "-50deg",
},
};
const HAND_TRANSITION: Transition = {
duration: 0.6,
ease: [0.4, 0, 0.2, 1],
};
const HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "100%",
},
animate: {
rotate: -360,
originX: "0%",
originY: "100%",
},
};
const MINUTE_HAND_TRANSITION: Transition = {
duration: 0.5,
ease: "easeInOut",
};
const MINUTE_HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "0%",
},
animate: {
rotate: -45,
originX: "0%",
originY: "0%",
},
};
const HistoryIcon = forwardRef<HistoryIconHandle, HistoryIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.g animate={controls} transition={ARROW_TRANSITION} variants={ARROW_VARIANTS}>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</motion.g>
<motion.line animate={controls} initial="normal" transition={HAND_TRANSITION} variants={HAND_VARIANTS} x1="12" x2="12" y1="12" y2="7"/>
<motion.line animate={controls} initial="normal" transition={MINUTE_HAND_TRANSITION} variants={MINUTE_HAND_VARIANTS} x1="12" x2="16" y1="12" y2="14"/>
</svg>
</div>);
});
HistoryIcon.displayName = "HistoryIcon";
export { HistoryIcon };
+62
View File
@@ -0,0 +1,62 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<motion.path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" variants={PATH_VARIANTS} transition={DEFAULT_TRANSITION} animate={controls}/>
</svg>
</div>);
});
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
@@ -0,0 +1,156 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
}
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false);
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const updateSelectionState = () => {
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
};
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
}
catch {
setCanPaste(false);
}
};
const handleCut = async () => {
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
input.value = newValue;
input.setSelectionRange(start, start);
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
}
catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
input.focus();
}
catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input)
return;
try {
const text = await navigator.clipboard.readText();
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue = input.value.substring(0, start) + text + input.value.substring(end);
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
}
catch (err) {
console.error("Failed to paste:", err);
}
};
const handleSelectAll = () => {
const input = inputRef.current;
if (!input)
return;
input.select();
input.focus();
updateSelectionState();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (<ContextMenu onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}>
<ContextMenuTrigger asChild>
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
<Scissors className="mr-2 h-4 w-4"/>
Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem>
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
<Copy className="mr-2 h-4 w-4"/>
Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem>
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
<Clipboard className="mr-2 h-4 w-4"/>
Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
<Type className="mr-2 h-4 w-4"/>
Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>);
});
InputWithContext.displayName = "InputWithContext";
export { InputWithContext };
+6
View File
@@ -0,0 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
}
export { Input };
+8
View File
@@ -0,0 +1,8 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
}
export { Label };
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const Menubar = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>>(({ className, ...props }, ref) => (<MenubarPrimitive.Root ref={ref} className={cn("flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", className)} {...props}/>));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const MenubarTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>>(({ className, ...props }, ref) => (<MenubarPrimitive.Trigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className)} {...props}/>));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}>(({ className, inset, children, ...props }, ref) => (<MenubarPrimitive.SubTrigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className)} {...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4"/>
</MenubarPrimitive.SubTrigger>));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>>(({ className, ...props }, ref) => (<MenubarPrimitive.SubContent ref={ref} className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)} {...props}/>));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Content>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (<MenubarPrimitive.Portal>
<MenubarPrimitive.Content ref={ref} align={align} alignOffset={alignOffset} sideOffset={sideOffset} className={cn("z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in slide-in-from-top-1", className)} {...props}/>
</MenubarPrimitive.Portal>));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Item>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)} {...props}/>));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>>(({ className, children, checked, ...props }, ref) => (<MenubarPrimitive.CheckboxItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} checked={checked} {...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4"/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>>(({ className, children, ...props }, ref) => (<MenubarPrimitive.RadioItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current"/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Label>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} {...props}/>));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>>(({ className, ...props }, ref) => (<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props}/>));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
};
MenubarShortcut.displayname = "MenubarShortcut";
export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, };

Some files were not shown because too many files have changed in this diff Show More