Compare commits
491 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe72286273 | |||
| 2b8ec744dd | |||
| 5f11f5b114 | |||
| 61f62363b3 | |||
| 3278e32711 | |||
| 0be6455d46 | |||
| 0bf5a39a92 | |||
| 5424648158 | |||
| dcfd95f276 | |||
| 4d6f7d8b08 | |||
| 2c2cf8cdf8 | |||
| 08c738dc69 | |||
| eb36b0bb7b | |||
| 3fd14e21eb | |||
| 408895b607 | |||
| 1a01147a95 | |||
| 8950907428 | |||
| eb40a88437 | |||
| 7f82049beb | |||
| c0c1d745f3 | |||
| c2b38a7c5a | |||
| ae8638a4b2 | |||
| b864fafa82 | |||
| ee5ab1a751 | |||
| 64b884e27a | |||
| dc8bb2cbc2 | |||
| d882fc292c | |||
| 5dc0980ced | |||
| 1cd668c869 | |||
| a827ebf6f4 | |||
| 3917ae02e2 | |||
| bd14c7dc63 | |||
| e0e28aee38 | |||
| 1550eedc12 | |||
| b2074dfd02 | |||
| e9171d6f21 | |||
| ef60bba2e1 | |||
| 12fb942f16 | |||
| 3a2481e8b2 | |||
| bede5ae8d7 | |||
| 445b186e3b | |||
| 354fe61b85 | |||
| 95f5ae610e | |||
| 2e806a28b9 | |||
| 2ab0350733 | |||
| ce813bc216 | |||
| 21fe047e00 | |||
| 8558450378 | |||
| f9e68b628d | |||
| 50509d0a16 | |||
| c1c0494912 | |||
| 58e615462c | |||
| f0bf769f0d | |||
| 423d50cfb5 | |||
| 2f4a62e03c | |||
| e64bea41e6 | |||
| f0acda0f01 | |||
| af4e4561ec | |||
| 1787059f42 | |||
| b2705cb2ae | |||
| f236d72a19 | |||
| cf270a36ff | |||
| 6d932386b0 | |||
| 9c054b9e3a | |||
| d9f0007a2d | |||
| ee35f52baf | |||
| 21347420f3 | |||
| 26987459f3 | |||
| 897388853b | |||
| ef52332b8b | |||
| 1489378ffd | |||
| ccc93f881a | |||
| ded8b68098 | |||
| 983be8b37a | |||
| 7b22bbf25f | |||
| 06f2b9ec97 | |||
| 7fee4cea4f | |||
| 526897b23b | |||
| c10c2a290c | |||
| fb5204b0a6 | |||
| 9db4048bc0 | |||
| 63c68b4d4d | |||
| 953ef37882 | |||
| da85a2dcc2 | |||
| 49869792cf | |||
| fb2dda1ed1 | |||
| fad4c4ea36 | |||
| 6b5345a6e5 | |||
| ca413a16fa | |||
| b8b670642c | |||
| 2a2e2924eb | |||
| adea3de737 | |||
| 7d300a39c9 | |||
| 688a5f2add | |||
| d736e5aafe | |||
| 3a536ad348 | |||
| 5dedeb4971 | |||
| 7624e24ea6 | |||
| 7b248d8ab4 | |||
| fdb2009856 | |||
| 8419a75b04 | |||
| 5d474d6fe8 | |||
| e597505a1c | |||
| 8675d263e7 | |||
| 1ce66b9e03 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 1623f443bb | |||
| aa47bc4499 | |||
| f461322842 | |||
| cce05a0077 | |||
| 98dc868f47 | |||
| 821a41c10e | |||
| 853ccd657a | |||
| 680fc81db2 | |||
| 36470eda24 | |||
| a37dd6c8cb | |||
| 588f742871 | |||
| ff25a10e5b | |||
| 499457f66a | |||
| 6d15050009 | |||
| 5ba30031c3 | |||
| 82c0eef504 | |||
| 616267e997 | |||
| 161b0c8c21 | |||
| facd185d6c | |||
| 42858bf336 | |||
| 716be88caf | |||
| b296726a9d | |||
| 092f18d7a5 | |||
| f1ef33e319 | |||
| fc9bc95418 | |||
| c61e64f332 | |||
| 70ebb8ef1a | |||
| a4c6a92478 | |||
| 76b453e535 | |||
| 19acdd87f5 | |||
| 492e1335ef | |||
| 23cde7add3 | |||
| a20c28db25 | |||
| f07d46c49e | |||
| e9781a24a6 | |||
| 15be15ba58 | |||
| 0952b76e11 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 7291dbd9e2 | |||
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 3a62442ed0 | |||
| 012dcdc2dd | |||
| 3a1b92f9c4 | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 2a2d817314 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 7845ac8be5 | |||
| 40770aff15 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 2bc5ef34ee | |||
| 939407675b | |||
| 6b9a3d95cd | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b | |||
| 4fe51cef96 | |||
| d005e2e2e7 | |||
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 672ce024f8 | |||
| 8224e93447 | |||
| 1ba810fffb | |||
| 1a725d0d31 | |||
| 51c5b42a78 | |||
| 2908827018 | |||
| b985cbf694 | |||
| 13c2360b7e | |||
| f1138ec7af | |||
| 1293d92896 | |||
| 705d41931d | |||
| 29de69d323 | |||
| 28727d89f6 | |||
| 4704bcf52f | |||
| 13c148fb6c | |||
| e6079452f9 | |||
| b68b7d5c9b | |||
| 741fcdb4d9 | |||
| 642f8c5398 | |||
| 1c15d5e7d3 | |||
| e71090338c | |||
| 7c0feaaae0 | |||
| 5aa3ff4bb5 | |||
| 0e00660e2e | |||
| aad72226c5 | |||
| d4c83db428 | |||
| 9f2d51fd4d | |||
| 83d7106e35 | |||
| 30a7cba02a | |||
| 01a5b43613 | |||
| 149cdc782d | |||
| 36137e8970 | |||
| d24435dbc2 | |||
| 823e56926f | |||
| bb06ab7e12 | |||
| 2143de3aa7 | |||
| dd8a54dd43 | |||
| 1ff33b96fa | |||
| b5973c45a2 | |||
| 9a78798854 | |||
| 101ab3f521 | |||
| cfc8e699f3 | |||
| 6b342aeac6 | |||
| b306056995 | |||
| 82e317c4a8 | |||
| a4dc776bfb | |||
| 5bdaa35ced | |||
| e187ac461d | |||
| 1b4a6cd042 | |||
| dcfb22c3f4 | |||
| 501158df03 | |||
| e17a4fad4e | |||
| 34894faabf | |||
| b329acd710 | |||
| 4be9273768 | |||
| f458ac2162 | |||
| b5ea2bb4c1 | |||
| 284d257921 | |||
| 30bf6b7f9a | |||
| 4941b6bd23 | |||
| 33d99817ec | |||
| 37e1af50ad | |||
| 8a6efb1303 | |||
| 7823b19b89 | |||
| 2a9aa544a9 | |||
| f387c8ff85 | |||
| 7e537aec0b | |||
| 66cd465565 | |||
| 87dc8eb5ea | |||
| 397669965d | |||
| 60bd0e619e | |||
| 2c7621c1a5 | |||
| 83afa40423 | |||
| 486e7eb101 | |||
| 05eb9e60d3 | |||
| dde7095644 | |||
| f1e9a2915d | |||
| ae3495d373 | |||
| 6fb2c1b688 | |||
| 1526c558e7 | |||
| 324e0f053b | |||
| 25cb33c78e | |||
| 942b6d9569 | |||
| cd46c79383 | |||
| 0bdcdcc229 | |||
| 1a5863a7fb | |||
| b55be00fab | |||
| f8b7812943 | |||
| 8f14ff169a | |||
| ca3abeb1cf | |||
| bb0cc23461 | |||
| 45fa33e1ec | |||
| 64dbf4441c | |||
| 148e5c1231 | |||
| 3a7419ec9f | |||
| 01c7c9cc3a | |||
| 701015ad55 | |||
| 3f56b88fa5 | |||
| bdd3f4aef5 | |||
| 611abdc6ae | |||
| 6e9fa45915 | |||
| 63cfac626a | |||
| e6c5a21bfc | |||
| 7dafbc1063 | |||
| ad8ac3bd2b | |||
| 2d80739141 | |||
| 6494102e15 | |||
| 0e6aa2efd9 | |||
| cd2c2a9854 | |||
| f412c216c5 | |||
| af15e3d914 | |||
| b00ff3f3f0 | |||
| 1607e6830e | |||
| 817e0bf2bd | |||
| 0f12fbce6a | |||
| 953a09d75f | |||
| 5098989614 | |||
| 5828bcffdd | |||
| ae87a7d58f | |||
| bb7c86c29e | |||
| 32ab78a213 | |||
| 69583d172c | |||
| 38367c1c77 | |||
| 2f6bf91a1c | |||
| 60b062bbaf | |||
| 30e8b604a9 | |||
| 7c3ab92e17 | |||
| 37b101c70f | |||
| b7be46e6ae | |||
| df96cc4a1d | |||
| 6c3d92cee4 | |||
| bf1f79866b | |||
| a6460426a2 | |||
| 304ba14d20 | |||
| db47233d92 | |||
| 74eeb98be8 | |||
| 331da0f897 | |||
| 73964ee648 | |||
| a5e8402141 | |||
| c5e7fcf29b | |||
| d3cf6d30a7 | |||
| 803cd2de96 | |||
| 8f2ca33e87 | |||
| d87e0d7e01 | |||
| 86b8709ea1 | |||
| 702b917929 | |||
| 74e14f7a43 | |||
| 02e347adb0 | |||
| 56983cb85b | |||
| 7917c656b0 | |||
| fc34c1e548 | |||
| f32aeaa0ff | |||
| 86097a932c | |||
| f74f24c41f | |||
| 8e99e7b07e | |||
| e06aab6e87 | |||
| a81e56fb26 | |||
| 9a09b119c5 | |||
| 4b28ca1055 | |||
| d684d9f8d1 | |||
| 16ce6089fb | |||
| 6895e45f2c | |||
| e87f7a1177 | |||
| bcd8a05352 | |||
| 4b219ad18e | |||
| 57051bd649 | |||
| d6fca6ca55 | |||
| 153ec2d9e5 | |||
| be90e85d94 | |||
| 4af089f56c | |||
| 62519d2d1c | |||
| 27c0880e87 | |||
| f312b74b30 | |||
| bd49e307ef | |||
| e904a836c1 | |||
| 763c9478f1 | |||
| 427bdf74dc | |||
| 373a276c54 | |||
| dccadf1f87 | |||
| d9933fe038 | |||
| d47ac0934d | |||
| dbba4d6630 | |||
| 7405855e01 | |||
| ed020c9303 | |||
| 378742e37a | |||
| c79bee534e | |||
| 1d6df75829 | |||
| b7f51b5f14 | |||
| 1c8e9df727 | |||
| 01540fe3fc | |||
| 071db2f109 | |||
| e097d3f605 | |||
| 277f783f62 | |||
| 7637aaf168 | |||
| c4878470bf | |||
| a3725e8c48 | |||
| 917ba842f5 | |||
| dac17ead33 | |||
| 6845ebe04c | |||
| eff709480d | |||
| 67833424cc | |||
| 5c48e1b476 | |||
| 5e17c9f238 | |||
| 7d330fb2ec | |||
| cd6a4594fa | |||
| bcf727f4ec | |||
| 4c4553913f | |||
| f0013fac16 | |||
| ce4be0ba97 | |||
| 4bac38ef2a | |||
| 4b213f47d9 | |||
| a1010f72f2 | |||
| 21077a26d0 | |||
| b50eec5a47 | |||
| 38a8b715f8 | |||
| 2b47537bb5 | |||
| a5cf241846 | |||
| 53a4773480 | |||
| 89603af1f1 | |||
| 2143084d3c | |||
| 0e265193b8 | |||
| c7e9749ce4 | |||
| e21cffff0b | |||
| d9e20040be | |||
| 6689173525 | |||
| f37e4704a6 | |||
| 65dbd5c8e4 | |||
| d034144e9c | |||
| 7c4309955e | |||
| 63e90d13d4 | |||
| bfb0cad603 | |||
| cc10a917dc | |||
| 5e833c1f75 | |||
| 8c576ac7e4 | |||
| 92160537c0 | |||
| 120ecaa0e5 | |||
| fd3a34303e | |||
| d89b70e155 | |||
| e3b63c1d27 | |||
| 96301c0dbf | |||
| a2458c1292 | |||
| 1737e12dd2 | |||
| b770d7d9ca | |||
| b712b9f509 | |||
| 51496cd34e | |||
| 2b2c2bc90a | |||
| e2a489ec92 | |||
| 4f46dd947d | |||
| fbb8d30db0 | |||
| c0637006af | |||
| 3fc371b8c4 | |||
| ee5b3824e9 | |||
| be6a856773 | |||
| e41c299d49 | |||
| 981786b4a2 | |||
| eeb6f11808 | |||
| 8e361e14b4 | |||
| d58d46eb1f | |||
| 562a17f7ae | |||
| b035e66540 | |||
| 38792a753e | |||
| d5b34b4f15 | |||
| 2a45c8dcdb | |||
| e7a2166a4f | |||
| f54597e655 |
@@ -66,7 +66,7 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "25"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -257,6 +257,15 @@ jobs:
|
|||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||||
|
run: |
|
||||||
|
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
perl -pi -e 's/\r$//' "$f"
|
||||||
|
chmod +x "$f"
|
||||||
|
echo "Normalized line endings: $f"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
@@ -379,8 +388,6 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
  
|
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -390,7 +397,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -556,7 +563,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -565,7 +572,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -575,7 +582,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
|
|||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/gobackend.aar
|
android/app/libs/gobackend.aar
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -57,17 +58,22 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
android/app/libs/gobackend-sources.jar
|
|
||||||
|
|
||||||
# Extension folder
|
# Extension folder
|
||||||
extension/
|
extension/*
|
||||||
|
extension/v2/
|
||||||
|
extension/v2/**
|
||||||
|
|
||||||
# Agent instructions
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
|
.tmp/
|
||||||
nul
|
nul
|
||||||
|
NUL
|
||||||
network_requests.txt
|
network_requests.txt
|
||||||
|
*.bak
|
||||||
|
/AndroidManifest.xml
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||||
```bash
|
```bash
|
||||||
fvm use
|
fvm use
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/17247">
|
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
|
|||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
|
|||||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
|
|||||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||||
- **Tidal** up to 24-bit/192kHz
|
|
||||||
- **Qobuz** up to 24-bit/192kHz
|
|
||||||
- **Deezer** up to 16-bit/44.1kHz
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -166,9 +163,8 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
|||||||
|
|
||||||
| | | | | |
|
| | | | | |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
riverpod_lint: 3.1.4-dev.3
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- build/**
|
- build/**
|
||||||
@@ -19,9 +22,6 @@ analyzer:
|
|||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
strict-raw-types: true
|
strict-raw-types: true
|
||||||
plugins:
|
|
||||||
- custom_lint
|
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -36,13 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
always_declare_return_types: true
|
||||||
avoid_dynamic_calls: true
|
avoid_dynamic_calls: true
|
||||||
|
avoid_types_as_parameter_names: true
|
||||||
|
strict_top_level_inference: true
|
||||||
|
type_annotate_public_apis: true
|
||||||
cancel_subscriptions: true
|
cancel_subscriptions: true
|
||||||
close_sinks: true
|
close_sinks: true
|
||||||
|
|
||||||
custom_lint:
|
|
||||||
rules:
|
|
||||||
- avoid_public_notifier_properties
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
id "kotlin-android"
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
if (flutterVersionCode == null) {
|
|
||||||
flutterVersionCode = '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
if (flutterVersionName == null) {
|
|
||||||
flutterVersionName = '1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "com.zarz.spotiflac"
|
|
||||||
compileSdk flutter.compileSdkVersion
|
|
||||||
ndkVersion flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '1.8'
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.zarz.spotiflac"
|
|
||||||
minSdkVersion flutter.minSdkVersion
|
|
||||||
targetSdk flutter.targetSdkVersion
|
|
||||||
versionCode flutterVersionCode.toInteger()
|
|
||||||
versionName flutterVersionName
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig signingConfigs.debug
|
|
||||||
minifyEnabled false
|
|
||||||
shrinkResources false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source '../..'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// Go backend library (gomobile generated)
|
|
||||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
|
||||||
|
|
||||||
// Kotlin coroutines for async Go backend calls
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
||||||
}
|
|
||||||
@@ -17,18 +17,22 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 37
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -58,6 +62,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
ndk {
|
ndk {
|
||||||
debugSymbolLevel = "FULL"
|
debugSymbolLevel = "FULL"
|
||||||
}
|
}
|
||||||
@@ -116,8 +122,9 @@ dependencies {
|
|||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||||
|
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC Mobile"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
@@ -86,6 +86,26 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="music.youtube.com" />
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="callback" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -94,6 +114,23 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:exported="true"
|
||||||
|
android:enabled="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- flutter_local_notifications receivers -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
@@ -110,6 +147,10 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.example.temp_project
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||||
|
* native workers.
|
||||||
|
*/
|
||||||
|
object SafDownloadHandler {
|
||||||
|
private val safDirLock = Any()
|
||||||
|
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||||
|
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||||
|
|
||||||
|
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||||
|
val req = JSONObject(requestJson)
|
||||||
|
val storageMode = req.optString("storage_mode", "")
|
||||||
|
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||||
|
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||||
|
return downloader(requestJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||||
|
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||||
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
|
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||||
|
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||||
|
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||||
|
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||||
|
|
||||||
|
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||||
|
if (existingDir != null) {
|
||||||
|
val existing = existingDir.findFile(fileName)
|
||||||
|
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||||
|
if (useStagedOutput || deferSafPublish) {
|
||||||
|
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||||
|
}
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("success", true)
|
||||||
|
obj.put("message", "File already exists")
|
||||||
|
obj.put("file_path", existing.uri.toString())
|
||||||
|
obj.put("file_name", existing.name ?: fileName)
|
||||||
|
obj.put("already_exists", true)
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||||
|
?: return errorJson("Failed to access SAF directory")
|
||||||
|
|
||||||
|
if (deferSafPublish) {
|
||||||
|
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||||
|
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||||
|
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||||
|
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||||
|
return try {
|
||||||
|
req.put("output_path", workingFile.absolutePath)
|
||||||
|
req.put("output_ext", outputExt)
|
||||||
|
req.remove("output_fd")
|
||||||
|
val response = downloader(req.toString())
|
||||||
|
val respObj = JSONObject(response)
|
||||||
|
if (respObj.optBoolean("success", false)) {
|
||||||
|
val reportedPath = respObj.optString("file_path", "").trim()
|
||||||
|
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||||
|
respObj.put("file_path", workingFile.absolutePath)
|
||||||
|
} else if (reportedPath != workingFile.absolutePath) {
|
||||||
|
workingFile.delete()
|
||||||
|
}
|
||||||
|
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||||
|
respObj.put("saf_deferred_publish", true)
|
||||||
|
respObj.put("saf_final_file_name", fileName)
|
||||||
|
respObj.put("saf_relative_dir", relativeDir)
|
||||||
|
respObj.put("saf_tree_uri", treeUriStr)
|
||||||
|
respObj.put("saf_output_ext", outputExt)
|
||||||
|
respObj.put("saf_final_mime_type", mimeType)
|
||||||
|
} else {
|
||||||
|
workingFile.delete()
|
||||||
|
}
|
||||||
|
respObj.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
workingFile.delete()
|
||||||
|
errorJson("SAF deferred download failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||||
|
?: return errorJson("Failed to create SAF file")
|
||||||
|
|
||||||
|
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||||
|
?: return errorJson("Failed to open SAF file")
|
||||||
|
|
||||||
|
var detachedFd: Int? = null
|
||||||
|
try {
|
||||||
|
detachedFd = pfd.detachFd()
|
||||||
|
req.put("output_path", "")
|
||||||
|
req.put("output_fd", detachedFd)
|
||||||
|
req.put("output_ext", outputExt)
|
||||||
|
val response = downloader(req.toString())
|
||||||
|
val respObj = JSONObject(response)
|
||||||
|
if (respObj.optBoolean("success", false)) {
|
||||||
|
val goFilePath = respObj.optString("file_path", "")
|
||||||
|
if (goFilePath.isNotEmpty() &&
|
||||||
|
!goFilePath.startsWith("content://") &&
|
||||||
|
!goFilePath.startsWith("/proc/self/fd/")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val srcFile = File(goFilePath)
|
||||||
|
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||||
|
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||||
|
}
|
||||||
|
val actualExt = normalizeExt(srcFile.extension)
|
||||||
|
if (actualExt.isNotBlank()) {
|
||||||
|
respObj.put("actual_extension", actualExt)
|
||||||
|
}
|
||||||
|
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||||
|
val actualFileName = buildSafFileName(req, actualExt)
|
||||||
|
val actualStagedFileName = if (useStagedOutput) {
|
||||||
|
buildStagedSafFileName(actualFileName)
|
||||||
|
} else {
|
||||||
|
actualFileName
|
||||||
|
}
|
||||||
|
val actualMimeType = mimeTypeForExt(actualExt)
|
||||||
|
val replacement = createOrReuseDocumentFile(
|
||||||
|
targetDir,
|
||||||
|
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||||
|
actualStagedFileName
|
||||||
|
) ?: throw IllegalStateException(
|
||||||
|
"failed to create SAF output with actual extension"
|
||||||
|
)
|
||||||
|
if (replacement.uri != document.uri) {
|
||||||
|
document.delete()
|
||||||
|
document = replacement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||||
|
srcFile.inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||||
|
srcFile.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
document.delete()
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Failed to copy extension output to SAF: ${e.message}"
|
||||||
|
)
|
||||||
|
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respObj.put("file_path", document.uri.toString())
|
||||||
|
respObj.put("file_name", document.name ?: fileName)
|
||||||
|
if (useStagedOutput) {
|
||||||
|
respObj.put("saf_staged_output", true)
|
||||||
|
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.delete()
|
||||||
|
}
|
||||||
|
return respObj.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
document.delete()
|
||||||
|
return errorJson("SAF download failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
if (detachedFd == null) {
|
||||||
|
try {
|
||||||
|
pfd.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||||
|
return try {
|
||||||
|
val uri = Uri.parse(uriStr)
|
||||||
|
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||||
|
?.name
|
||||||
|
?.substringAfterLast('.', "")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { ".$it" }
|
||||||
|
?: ".tmp"
|
||||||
|
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
temp.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
temp.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeFileToSaf(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
srcPath: String
|
||||||
|
): String? {
|
||||||
|
var stagedDocument: DocumentFile? = null
|
||||||
|
return try {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||||
|
val finalName = sanitizeFilename(fileName)
|
||||||
|
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||||
|
val stagedName = buildStagedSafFileName(finalName)
|
||||||
|
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||||
|
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||||
|
?: return null
|
||||||
|
stagedDocument = document
|
||||||
|
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||||
|
if (outputStream == null) {
|
||||||
|
document.delete()
|
||||||
|
stagedDocument = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
outputStream.use { output ->
|
||||||
|
File(srcPath).inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingFinal = targetDir.findFile(finalName)
|
||||||
|
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||||
|
existingFinal.delete()
|
||||||
|
}
|
||||||
|
if (!document.renameTo(finalName)) {
|
||||||
|
document.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
stagedDocument = null
|
||||||
|
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stagedDocument?.delete()
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||||
|
return try {
|
||||||
|
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeExt(ext: String?): String {
|
||||||
|
if (ext.isNullOrBlank()) return ""
|
||||||
|
return if (ext.startsWith(".")) {
|
||||||
|
ext.lowercase(Locale.ROOT)
|
||||||
|
} else {
|
||||||
|
".${ext.lowercase(Locale.ROOT)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeForExt(ext: String?): String {
|
||||||
|
return when (normalizeExt(ext)) {
|
||||||
|
".m4a", ".mp4" -> "audio/mp4"
|
||||||
|
".mp3" -> "audio/mpeg"
|
||||||
|
".opus" -> "audio/ogg"
|
||||||
|
".flac" -> "audio/flac"
|
||||||
|
".lrc" -> "application/octet-stream"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||||
|
val normalizedExt = normalizeExt(outputExt)
|
||||||
|
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||||
|
|
||||||
|
val safeName = sanitizeFilename(name)
|
||||||
|
val lower = safeName.lowercase(Locale.ROOT)
|
||||||
|
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
||||||
|
for (knownExt in knownExts) {
|
||||||
|
if (lower.endsWith(knownExt)) {
|
||||||
|
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safeName + normalizedExt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStagedSafFileName(fileName: String): String {
|
||||||
|
val safeName = sanitizeFilename(fileName)
|
||||||
|
return "$safeName.partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||||
|
val safeName = sanitizeFilename(fileName)
|
||||||
|
val ext = normalizeExt(outputExt)
|
||||||
|
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||||
|
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||||
|
}
|
||||||
|
val dot = safeName.lastIndexOf('.')
|
||||||
|
if (dot > 0 && dot < safeName.lastIndex) {
|
||||||
|
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||||
|
".partial" +
|
||||||
|
safeName.substring(dot)
|
||||||
|
}
|
||||||
|
return "$safeName.partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||||
|
val stagedNames = linkedSetOf(
|
||||||
|
buildStagedSafFileName(fileName),
|
||||||
|
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||||
|
)
|
||||||
|
for (stagedName in stagedNames) {
|
||||||
|
try {
|
||||||
|
parent.findFile(stagedName)?.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeFilename(name: String): String {
|
||||||
|
var sanitized = name
|
||||||
|
.replace("/", " ")
|
||||||
|
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||||
|
.filter { ch ->
|
||||||
|
val code = ch.code
|
||||||
|
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||||
|
code == 0x7F ||
|
||||||
|
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||||
|
}
|
||||||
|
.trim()
|
||||||
|
.trim('.', ' ')
|
||||||
|
|
||||||
|
sanitized = sanitized
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.replace(Regex("_+"), "_")
|
||||||
|
.trim('_', ' ')
|
||||||
|
|
||||||
|
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||||
|
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||||
|
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||||
|
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||||
|
|
||||||
|
val dotIndex = name.lastIndexOf('.')
|
||||||
|
val ext = if (
|
||||||
|
dotIndex > 0 &&
|
||||||
|
dotIndex < name.length - 1 &&
|
||||||
|
name.length - dotIndex <= 10
|
||||||
|
) {
|
||||||
|
name.substring(dotIndex)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||||
|
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||||
|
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||||
|
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||||
|
|
||||||
|
val builder = StringBuilder()
|
||||||
|
var usedBytes = 0
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val codePoint = value.codePointAt(index)
|
||||||
|
val char = String(Character.toChars(codePoint))
|
||||||
|
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||||
|
if (usedBytes + charBytes > maxBytes) break
|
||||||
|
builder.append(char)
|
||||||
|
usedBytes += charBytes
|
||||||
|
index += Character.charCount(codePoint)
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||||
|
if (relativeDir.isBlank()) return ""
|
||||||
|
return relativeDir
|
||||||
|
.split("/")
|
||||||
|
.map { sanitizeFilename(it) }
|
||||||
|
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||||
|
.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureDocumentDir(
|
||||||
|
context: Context,
|
||||||
|
treeUri: Uri,
|
||||||
|
relativeDir: String
|
||||||
|
): DocumentFile? {
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) {
|
||||||
|
return DocumentFile.fromTreeUri(context, treeUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
current = if (existing != null && existing.isDirectory) {
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val created = current.createDirectory(part) ?: return null
|
||||||
|
val createdName = created.name ?: part
|
||||||
|
if (createdName != part) {
|
||||||
|
created.delete()
|
||||||
|
current.findFile(part) ?: return null
|
||||||
|
} else {
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findDocumentDir(
|
||||||
|
context: Context,
|
||||||
|
treeUri: Uri,
|
||||||
|
relativeDir: String
|
||||||
|
): DocumentFile? {
|
||||||
|
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) return current
|
||||||
|
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
if (existing == null || !existing.isDirectory) return null
|
||||||
|
current = existing
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOrReuseDocumentFile(
|
||||||
|
parent: DocumentFile,
|
||||||
|
mimeType: String,
|
||||||
|
fileName: String
|
||||||
|
): DocumentFile? {
|
||||||
|
val safeFileName = sanitizeFilename(fileName)
|
||||||
|
if (safeFileName.isBlank()) return null
|
||||||
|
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
val existing = parent.findFile(safeFileName)
|
||||||
|
if (existing != null && existing.isFile) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||||
|
val createdName = created.name ?: safeFileName
|
||||||
|
if (createdName == safeFileName) {
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
val winner = parent.findFile(safeFileName)
|
||||||
|
if (winner != null && winner.isFile) {
|
||||||
|
if (winner.uri != created.uri) {
|
||||||
|
try {
|
||||||
|
created.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return winner
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||||
|
val provided = req.optString("saf_file_name", "")
|
||||||
|
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||||
|
|
||||||
|
val trackName = req.optString("track_name", "track")
|
||||||
|
val artistName = req.optString("artist_name", "")
|
||||||
|
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||||
|
return forceFilenameExt(baseName, outputExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorJson(message: String): String {
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("success", false)
|
||||||
|
obj.put("error", message)
|
||||||
|
obj.put("message", message)
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -6,4 +6,9 @@
|
|||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,17 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media" />
|
||||||
|
</automotiveApp>
|
||||||
@@ -11,8 +11,8 @@ subprojects {
|
|||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "SpotiFLAC Source",
|
"name": "SpotiFLAC Mobile Source",
|
||||||
"identifier": "com.zarzet.spotiflac.source",
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
"subtitle": "FLAC Downloader for iOS",
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"name": "SpotiFLAC",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "3.9.0",
|
"version": "4.7.1",
|
||||||
"versionDate": "2026-03-25",
|
"versionDate": "2026-07-01",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 34477323
|
"size": 37455821
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -3,17 +3,21 @@ files:
|
|||||||
translation: /lib/l10n/arb/app_%locale%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
languages_mapping:
|
languages_mapping:
|
||||||
locale:
|
locale:
|
||||||
# Short codes for single-variant languages
|
# Keys MUST be the project's Crowdin language ids; values are the
|
||||||
|
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||||
|
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||||
|
ar: ar
|
||||||
de: de
|
de: de
|
||||||
es: es
|
es-ES: es_ES
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
hi: hi
|
||||||
id: id
|
id: id
|
||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
nl: nl
|
||||||
pt: pt
|
pt-PT: pt_PT
|
||||||
ru: ru
|
ru: ru
|
||||||
# Full codes for Chinese variants
|
tr: tr
|
||||||
|
uk: uk
|
||||||
zh-CN: zh_CN
|
zh-CN: zh_CN
|
||||||
zh-TW: zh_TW
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||||
|
type mp4Box struct {
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
hdr int64
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||||
|
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||||
|
|
||||||
|
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||||
|
n := int64(len(data))
|
||||||
|
if pos < 0 || pos+8 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||||
|
typ := string(data[pos+4 : pos+8])
|
||||||
|
hdr := int64(8)
|
||||||
|
if size == 1 {
|
||||||
|
if pos+16 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||||
|
hdr = 16
|
||||||
|
} else if size == 0 {
|
||||||
|
size = n - pos
|
||||||
|
}
|
||||||
|
if size < hdr || pos+size > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
if b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.typ == typ && !fn(b) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||||
|
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||||
|
// which may be nested inside an encrypted (enca) sample entry.
|
||||||
|
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
if len(typ) != 4 {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
for i := start; i+8 <= end; i++ {
|
||||||
|
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||||
|
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||||
|
// entry header (from the box body start) before child boxes begin. ok is false
|
||||||
|
// for malformed/truncated entries whose declared header is not fully present.
|
||||||
|
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||||
|
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||||
|
base := entry.body()
|
||||||
|
if base+10 > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||||
|
hdrLen = 8 + 20
|
||||||
|
switch version {
|
||||||
|
case 1:
|
||||||
|
hdrLen += 16
|
||||||
|
case 2:
|
||||||
|
hdrLen += 36
|
||||||
|
}
|
||||||
|
if base+hdrLen > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hdrLen, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ac4Location struct {
|
||||||
|
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||||
|
entry mp4Box // the ac-4 sample entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return ac4Location{}, false
|
||||||
|
}
|
||||||
|
var found ac4Location
|
||||||
|
var ok2 bool
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||||
|
ok2 = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return found, ok2
|
||||||
|
}
|
||||||
|
|
||||||
|
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||||
|
if b.hdr == 16 {
|
||||||
|
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||||
|
} else {
|
||||||
|
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||||
|
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||||
|
// inserted into moov.
|
||||||
|
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||||
|
base := stco.body() + 4
|
||||||
|
if base+4 <= stco.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||||
|
}
|
||||||
|
p += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||||
|
base := co64.body() + 4
|
||||||
|
if base+4 <= co64.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||||
|
}
|
||||||
|
p += 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||||
|
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||||
|
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||||
|
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||||
|
// flavor for AC-4 even when dac4 is present.
|
||||||
|
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||||
|
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
|
||||||
|
if ftyp.body()+4 <= int64(len(data)) {
|
||||||
|
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||||
|
}
|
||||||
|
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||||
|
if string(data[p:p+4]) == "qt " {
|
||||||
|
copy(data[p:p+4], []byte("isom"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(data)
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
entry := loc.entry
|
||||||
|
verPos := entry.body() + 8
|
||||||
|
if verPos+2 > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||||
|
return data // already v0 (or v2, left untouched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||||
|
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||||
|
extStart := entry.body() + 8 + 20
|
||||||
|
extEnd := extStart + 16
|
||||||
|
if extEnd > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
delta := int64(-16)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||||
|
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(data, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(data, entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(data)-16)
|
||||||
|
out = append(out, data[:extStart]...)
|
||||||
|
out = append(out, data[extEnd:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||||
|
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||||
|
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||||
|
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||||
|
// moov still carries it). No-op when the file has no AC-4 track.
|
||||||
|
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||||
|
dst, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := locateAC4Entry(dst); !ok {
|
||||||
|
return nil // not an AC-4 file; nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
dst = normalizeQuickTimeAudioToMP4(dst)
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(dst)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("malformed ac-4 sample entry")
|
||||||
|
}
|
||||||
|
childStart := loc.entry.body() + hdrLen
|
||||||
|
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||||
|
// Already has dac4; still persist any normalization changes.
|
||||||
|
return os.WriteFile(decryptedPath, dst, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("source has no moov")
|
||||||
|
}
|
||||||
|
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("dac4 not found in source")
|
||||||
|
}
|
||||||
|
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
|
||||||
|
|
||||||
|
insertPos := childStart
|
||||||
|
delta := int64(len(dac4))
|
||||||
|
|
||||||
|
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(dst, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(dst, loc.entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(dst)+len(dac4))
|
||||||
|
out = append(out, dst[:insertPos]...)
|
||||||
|
out = append(out, dac4...)
|
||||||
|
out = append(out, dst[insertPos:]...)
|
||||||
|
|
||||||
|
return os.WriteFile(decryptedPath, out, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mp4TestBox(typ string, body []byte) []byte {
|
||||||
|
out := make([]byte, 8+len(body))
|
||||||
|
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||||
|
copy(out[4:8], typ)
|
||||||
|
copy(out[8:], body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||||
|
entry := mp4TestBox("ac-4", entryBody)
|
||||||
|
stsdBody := append([]byte{
|
||||||
|
0, 0, 0, 0, // version/flags
|
||||||
|
0, 0, 0, 1, // entry_count
|
||||||
|
}, entry...)
|
||||||
|
stsd := mp4TestBox("stsd", stsdBody)
|
||||||
|
stbl := mp4TestBox("stbl", stsd)
|
||||||
|
minf := mp4TestBox("minf", stbl)
|
||||||
|
mdia := mp4TestBox("mdia", minf)
|
||||||
|
trak := mp4TestBox("trak", mdia)
|
||||||
|
moov := mp4TestBox("moov", trak)
|
||||||
|
return moov
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||||
|
body := make([]byte, 10)
|
||||||
|
binary.BigEndian.PutUint16(body[8:10], version)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||||
|
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||||
|
if !bytes.Equal(got, input) {
|
||||||
|
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||||
|
sourcePath := filepath.Join(dir, "source.mp4")
|
||||||
|
|
||||||
|
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||||
|
t.Fatal("expected malformed AC-4 sample entry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||||
|
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||||
|
type ac4Metadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Composer string `json:"composer"`
|
||||||
|
TrackNumber string `json:"trackNumber"`
|
||||||
|
TotalTracks string `json:"totalTracks"`
|
||||||
|
DiscNumber string `json:"discNumber"`
|
||||||
|
TotalDiscs string `json:"totalDiscs"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Lyrics string `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiSafe(s string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesTextTag(atomType, value string) []byte {
|
||||||
|
data := make([]byte, 8+len(value))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||||
|
copy(data[8:], []byte(value))
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||||
|
payload := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||||
|
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||||
|
data := make([]byte, 8+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||||
|
copy(data[8:], payload)
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesCoverTag(image []byte) []byte {
|
||||||
|
typeCode := uint32(13) // JPEG
|
||||||
|
if len(image) >= 8 &&
|
||||||
|
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||||
|
typeCode = 14 // PNG
|
||||||
|
}
|
||||||
|
data := make([]byte, 8+len(image))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||||
|
copy(data[8:], image)
|
||||||
|
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesMetadataHandler() []byte {
|
||||||
|
payload := make([]byte, 0, 25)
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||||
|
payload = append(payload, []byte("mdir")...) // handler type
|
||||||
|
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||||
|
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||||
|
payload = append(payload, 0) // empty name
|
||||||
|
return buildM4AAtom("hdlr", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||||
|
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||||
|
ilst := make([]byte, 0, 256)
|
||||||
|
add := func(atomType, value string) {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("\xa9nam", md.Title)
|
||||||
|
add("\xa9ART", md.Artist)
|
||||||
|
add("\xa9alb", md.Album)
|
||||||
|
add("aART", md.AlbumArtist)
|
||||||
|
add("\xa9day", md.Date)
|
||||||
|
add("\xa9gen", md.Genre)
|
||||||
|
add("\xa9wrt", md.Composer)
|
||||||
|
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||||
|
}
|
||||||
|
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.ISRC) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Label) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Copyright) != "" {
|
||||||
|
add("cprt", md.Copyright)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Lyrics) != "" {
|
||||||
|
add("\xa9lyr", md.Lyrics)
|
||||||
|
}
|
||||||
|
if len(cover) > 0 {
|
||||||
|
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
ilstBox := buildM4AAtom("ilst", ilst)
|
||||||
|
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||||
|
metaPayload = append(metaPayload, ilstBox...)
|
||||||
|
meta := buildM4AAtom("meta", metaPayload)
|
||||||
|
return buildM4AAtom("udta", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||||
|
// the moov of an MP4 buffer and returns the rewritten bytes.
|
||||||
|
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
newUdta := buildITunesUdta(md, cover)
|
||||||
|
|
||||||
|
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||||
|
delta := int64(len(newUdta)) - udta.size
|
||||||
|
shiftChunkOffsets(data, moov, udta.offset, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:udta.offset]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[udta.end():]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := int64(len(newUdta))
|
||||||
|
insertPos := moov.end()
|
||||||
|
shiftChunkOffsets(data, moov, insertPos, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:insertPos]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[insertPos:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||||
|
// true when the file was an AC-4 track and metadata was written; false when the
|
||||||
|
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||||
|
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, ok := locateAC4Entry(data); !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var md ac4Metadata
|
||||||
|
if strings.TrimSpace(metadataJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||||
|
}
|
||||||
|
var cover []byte
|
||||||
|
if strings.TrimSpace(coverPath) != "" {
|
||||||
|
if b, err := os.ReadFile(coverPath); err == nil {
|
||||||
|
cover = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := writeMP4iTunesMetadata(data, md, cover)
|
||||||
|
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -56,7 +56,6 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find APE tag footer at the end of file.
|
|
||||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -255,7 +254,6 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
|||||||
|
|
||||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
// Check if there's also a header (tagSize only covers items + footer)
|
|
||||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
totalSize := tagSize
|
totalSize := tagSize
|
||||||
if hasHeader {
|
if hasHeader {
|
||||||
@@ -316,7 +314,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
|||||||
footerFlags := uint32(1 << 31)
|
footerFlags := uint32(1 << 31)
|
||||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
// Final layout: header + items + footer
|
|
||||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
result = append(result, header...)
|
result = append(result, header...)
|
||||||
result = append(result, itemsData...)
|
result = append(result, itemsData...)
|
||||||
@@ -511,7 +508,6 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
// deletion: the caller sends an empty value which is not serialized into
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
// newItems, but the old value must still be dropped.
|
// newItems, but the old value must still be dropped.
|
||||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
// Build a set of keys being updated (upper-case for case-insensitive match)
|
|
||||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
for k := range overrideKeys {
|
for k := range overrideKeys {
|
||||||
combined[strings.ToUpper(k)] = struct{}{}
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
@@ -539,7 +535,6 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try footer at end of file
|
|
||||||
footer := make([]byte, apeTagHeaderSize)
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sample.ape")
|
||||||
|
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
||||||
|
t.Fatalf("write sample: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{
|
||||||
|
Title: "Song",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
AlbumArtist: "Album Artist",
|
||||||
|
Genre: "Pop",
|
||||||
|
Date: "2026",
|
||||||
|
TrackNumber: 3,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 1,
|
||||||
|
TotalDiscs: 2,
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
Lyrics: "lyrics",
|
||||||
|
Label: "Label",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
Composer: "Composer",
|
||||||
|
Comment: "Comment",
|
||||||
|
ReplayGainTrackGain: "-6.50 dB",
|
||||||
|
ReplayGainTrackPeak: "0.98",
|
||||||
|
ReplayGainAlbumGain: "-5.00 dB",
|
||||||
|
ReplayGainAlbumPeak: "0.99",
|
||||||
|
}
|
||||||
|
items := AudioMetadataToAPEItems(metadata)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("expected APE items")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
||||||
|
if err := WriteAPETags(path, tag); err != nil {
|
||||||
|
t.Fatalf("WriteAPETags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readTag, err := ReadAPETags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAPETags: %v", err)
|
||||||
|
}
|
||||||
|
if readTag.Version != apeTagVersion2 {
|
||||||
|
t.Fatalf("version = %d", readTag.Version)
|
||||||
|
}
|
||||||
|
readMetadata := APETagToAudioMetadata(readTag)
|
||||||
|
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||||
|
t.Fatalf("metadata = %#v", readMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
||||||
|
}
|
||||||
|
if len(readerTag.Items) != len(readTag.Items) {
|
||||||
|
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
||||||
|
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
||||||
|
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
||||||
|
if mergedMeta.Title != "New Song" {
|
||||||
|
t.Fatalf("merged title = %q", mergedMeta.Title)
|
||||||
|
}
|
||||||
|
if mergedMeta.Lyrics != "" {
|
||||||
|
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
||||||
|
t.Fatalf("replace APE tags: %v", err)
|
||||||
|
}
|
||||||
|
replaced, err := ReadAPETags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read replacement: %v", err)
|
||||||
|
}
|
||||||
|
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
||||||
|
t.Fatalf("replacement title = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := marshalAPETag(nil); err == nil {
|
||||||
|
t.Fatal("expected empty tag error")
|
||||||
|
}
|
||||||
|
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
||||||
|
t.Fatal("expected missing file error")
|
||||||
|
}
|
||||||
|
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
||||||
|
t.Fatal("expected small reader error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
||||||
|
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected unsupported version")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected small tag size")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected too many items")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected header flag error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
return data, mimeType, nil
|
return data, mimeType, nil
|
||||||
|
|
||||||
|
case ".wav", ".aiff", ".aif", ".aifc":
|
||||||
|
return extractWAVAIFFCover(filePath)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "tagged.mp3")
|
||||||
|
tag := buildID3v23Tag(
|
||||||
|
id3TextFrame("TIT2", "Title"),
|
||||||
|
id3TextFrame("TPE1", "Artist"),
|
||||||
|
id3TextFrame("TPE2", "Album Artist"),
|
||||||
|
id3TextFrame("TALB", "Album"),
|
||||||
|
id3TextFrame("TDRC", "2026-05-04"),
|
||||||
|
id3TextFrame("TCON", "(13)Pop"),
|
||||||
|
id3TextFrame("TRCK", "4/12"),
|
||||||
|
id3TextFrame("TPOS", "1/2"),
|
||||||
|
id3TextFrame("TSRC", "USRC17607839"),
|
||||||
|
id3TextFrame("TCOM", "Composer"),
|
||||||
|
id3TextFrame("TPUB", "Label"),
|
||||||
|
id3TextFrame("TCOP", "Copyright"),
|
||||||
|
id3CommentFrame("COMM", "Comment"),
|
||||||
|
id3CommentFrame("USLT", "Lyrics"),
|
||||||
|
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||||
|
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
||||||
|
t.Fatalf("metadata = %#v", meta)
|
||||||
|
}
|
||||||
|
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||||
|
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||||
|
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v1: %v", err)
|
||||||
|
}
|
||||||
|
v1, err := ReadID3Tags(id3v1Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags v1: %v", err)
|
||||||
|
}
|
||||||
|
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
||||||
|
t.Fatalf("v1 = %#v", v1)
|
||||||
|
}
|
||||||
|
|
||||||
|
v22Path := filepath.Join(dir, "id3v22.mp3")
|
||||||
|
v22 := buildID3v22Tag(
|
||||||
|
id3v22TextFrame("TT2", "V22 Title"),
|
||||||
|
id3v22TextFrame("TP1", "V22 Artist"),
|
||||||
|
id3v22TextFrame("TRK", "2/5"),
|
||||||
|
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v2.2: %v", err)
|
||||||
|
}
|
||||||
|
v22Meta, err := ReadID3Tags(v22Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
||||||
|
}
|
||||||
|
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
||||||
|
t.Fatalf("v22 = %#v", v22Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
||||||
|
t.Fatalf("decodeUTF16 = %q", got)
|
||||||
|
}
|
||||||
|
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
||||||
|
t.Fatalf("decodeUTF16BE = %q", got)
|
||||||
|
}
|
||||||
|
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||||
|
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||||
|
}
|
||||||
|
if got := parseTrackNumber("9/11"); got != 9 {
|
||||||
|
t.Fatalf("parseTrackNumber = %d", got)
|
||||||
|
}
|
||||||
|
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||||
|
t.Fatalf("removeUnsync = %#v", got)
|
||||||
|
}
|
||||||
|
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
||||||
|
t.Fatalf("extendedHeaderSize = %d", got)
|
||||||
|
}
|
||||||
|
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
||||||
|
t.Fatalf("syncsafe = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||||
|
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
||||||
|
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
||||||
|
t.Fatal("cover MIME detection mismatch")
|
||||||
|
}
|
||||||
|
if _, err := buildPictureBlock("", nil); err == nil {
|
||||||
|
t.Fatal("expected empty picture block error")
|
||||||
|
}
|
||||||
|
|
||||||
|
apic := append([]byte{3}, []byte("image/png\x00")...)
|
||||||
|
apic = append(apic, 3, 0)
|
||||||
|
apic = append(apic, png...)
|
||||||
|
image, mime := parseAPICFrame(apic, 3)
|
||||||
|
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||||
|
t.Fatalf("APIC = %s/%v", mime, image)
|
||||||
|
}
|
||||||
|
pic := append([]byte{0}, []byte("PNG")...)
|
||||||
|
pic = append(pic, 3, 0)
|
||||||
|
pic = append(pic, png...)
|
||||||
|
image, mime = parseAPICFrame(pic, 2)
|
||||||
|
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||||
|
t.Fatalf("PIC = %s/%v", mime, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := make([]byte, 10)
|
||||||
|
copy(frame[:4], "APIC")
|
||||||
|
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
||||||
|
tag := append(frame, apic...)
|
||||||
|
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
||||||
|
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
||||||
|
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
||||||
|
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
||||||
|
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var picture bytes.Buffer
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
||||||
|
picture.WriteString("image/png")
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
||||||
|
picture.Write(png)
|
||||||
|
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
||||||
|
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
||||||
|
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
||||||
|
var vorbis bytes.Buffer
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
||||||
|
vorbis.WriteString("vendor")
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
||||||
|
vorbis.WriteString(comment)
|
||||||
|
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
||||||
|
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||||
|
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||||
|
}
|
||||||
|
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
|
||||||
|
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
|
||||||
|
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
|
||||||
|
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||||
|
t.Fatal("expected opus stream")
|
||||||
|
}
|
||||||
|
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
||||||
|
t.Fatal("expected vorbis stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
||||||
|
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
||||||
|
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quality, err := GetMP3Quality(mp3Path)
|
||||||
|
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
||||||
|
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
||||||
|
t.Fatal("expected missing MP3 cover error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "tagged.m4a")
|
||||||
|
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||||
|
ilstPayload := []byte{}
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||||
|
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||||
|
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||||
|
t.Fatalf("M4A metadata = %#v", meta)
|
||||||
|
}
|
||||||
|
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||||
|
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
||||||
|
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
||||||
|
}
|
||||||
|
if pathInfo, err := func() (m4aMetadataPath, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return m4aMetadataPath{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, _ := f.Stat()
|
||||||
|
return findM4AMetadataPath(f, info.Size())
|
||||||
|
}(); err != nil || pathInfo.udta == nil {
|
||||||
|
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
||||||
|
}
|
||||||
|
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
||||||
|
t.Fatalf("EditM4AReplayGain: %v", err)
|
||||||
|
}
|
||||||
|
edited, err := ReadM4ATags(path)
|
||||||
|
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
||||||
|
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
||||||
|
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
||||||
|
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
||||||
|
}
|
||||||
|
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
||||||
|
t.Fatal("expected missing M4A error")
|
||||||
|
}
|
||||||
|
emptyM4A := filepath.Join(dir, "empty.m4a")
|
||||||
|
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected empty M4A tags error")
|
||||||
|
}
|
||||||
|
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected missing M4A cover error")
|
||||||
|
}
|
||||||
|
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected missing M4A lyrics error")
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
||||||
|
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
||||||
|
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
||||||
|
t.Fatal("embedded lyric heuristic mismatch")
|
||||||
|
}
|
||||||
|
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
||||||
|
t.Fatal("formatIndexValue mismatch")
|
||||||
|
}
|
||||||
|
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
||||||
|
t.Fatal("parsePositiveInt mismatch")
|
||||||
|
}
|
||||||
|
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
||||||
|
t.Fatal("expected map key")
|
||||||
|
}
|
||||||
|
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
||||||
|
t.Fatal("expected ReplayGain dB parse")
|
||||||
|
}
|
||||||
|
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
||||||
|
t.Fatal("expected ReplayGain peak parse")
|
||||||
|
}
|
||||||
|
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
||||||
|
t.Fatal("expected iTunNORM")
|
||||||
|
}
|
||||||
|
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
||||||
|
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||||
|
mvhd := make([]byte, 20)
|
||||||
|
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||||
|
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||||
|
sampleEntry := make([]byte, 32)
|
||||||
|
copy(sampleEntry[0:4], "alac")
|
||||||
|
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||||
|
sampleEntry[28] = 0xAC
|
||||||
|
sampleEntry[29] = 0x44
|
||||||
|
alacConfig := make([]byte, 24)
|
||||||
|
alacConfig[5] = 24
|
||||||
|
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||||
|
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||||
|
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||||
|
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||||
|
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||||
|
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||||
|
copy(sampleEntry[0:4], "mp4a")
|
||||||
|
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||||
|
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||||
|
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||||
|
zeroMvhd := make([]byte, 20)
|
||||||
|
eac3SampleEntry := make([]byte, 32)
|
||||||
|
copy(eac3SampleEntry[0:4], "ec-3")
|
||||||
|
eac3SampleEntry[28] = 0xBB
|
||||||
|
eac3SampleEntry[29] = 0x80
|
||||||
|
mdhd := make([]byte, 20)
|
||||||
|
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||||
|
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||||
|
eac3QualityFile := append(
|
||||||
|
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||||
|
buildM4AAtom("moov", append(
|
||||||
|
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||||
|
eac3SampleEntry...,
|
||||||
|
))...,
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||||
|
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||||
|
t.Fatal("short ALAC config should not parse")
|
||||||
|
}
|
||||||
|
alac := make([]byte, 24)
|
||||||
|
alac[5] = 16
|
||||||
|
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
||||||
|
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
||||||
|
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
opusHead := make([]byte, 19)
|
||||||
|
copy(opusHead[0:8], "OpusHead")
|
||||||
|
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
||||||
|
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
||||||
|
|
||||||
|
var comments bytes.Buffer
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
||||||
|
comments.WriteString("vendor")
|
||||||
|
entries := []string{
|
||||||
|
"TITLE=Ogg Title",
|
||||||
|
"ARTIST=Artist",
|
||||||
|
"ALBUMARTIST=Album Artist",
|
||||||
|
"TRACKNUMBER=2/9",
|
||||||
|
"DISCNUMBER=1/2",
|
||||||
|
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||||
|
}
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||||
|
for _, entry := range entries {
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
||||||
|
comments.WriteString(entry)
|
||||||
|
}
|
||||||
|
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
||||||
|
oggPath := filepath.Join(dir, "tagged.opus")
|
||||||
|
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
||||||
|
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quality, err := GetOggQuality(oggPath)
|
||||||
|
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
||||||
|
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
meta, err := ReadOggVorbisComments(oggPath)
|
||||||
|
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||||
|
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||||
|
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||||
|
var coverComments bytes.Buffer
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
||||||
|
coverComments.WriteString("vendor")
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
||||||
|
coverComments.WriteString(pictureComment)
|
||||||
|
coverPath := filepath.Join(dir, "cover.opus")
|
||||||
|
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
||||||
|
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||||
|
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
||||||
|
}
|
||||||
|
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||||
|
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||||
|
}
|
||||||
|
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||||
|
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
|
||||||
|
}
|
||||||
|
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||||
|
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||||
|
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||||
|
}
|
||||||
|
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
||||||
|
t.Fatal("expected extracted cover data")
|
||||||
|
}
|
||||||
|
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
||||||
|
if err != nil || cachePath == "" {
|
||||||
|
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||||
|
}
|
||||||
|
cacheDir := filepath.Join(dir, "cache")
|
||||||
|
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
|
||||||
|
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
|
||||||
|
}
|
||||||
|
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
|
||||||
|
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
|
||||||
|
}
|
||||||
|
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
|
||||||
|
if err != nil || hitPath == "" {
|
||||||
|
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
|
||||||
|
}
|
||||||
|
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||||
|
t.Fatal("expected missing cover cache error")
|
||||||
|
}
|
||||||
|
|
||||||
|
badPath := filepath.Join(dir, "bad.ogg")
|
||||||
|
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := GetOggQuality(badPath); err == nil {
|
||||||
|
t.Fatal("expected invalid Ogg quality error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4ADataPayload(payload []byte) []byte {
|
||||||
|
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4ATextTag(atomType, value string) []byte {
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
||||||
|
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
||||||
|
ilst := buildM4AAtom("ilst", ilstPayload)
|
||||||
|
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
||||||
|
moovPayload := meta
|
||||||
|
if withUdta {
|
||||||
|
moovPayload = buildM4AAtom("udta", meta)
|
||||||
|
}
|
||||||
|
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
||||||
|
header := make([]byte, 27)
|
||||||
|
copy(header[0:4], "OggS")
|
||||||
|
header[4] = 0
|
||||||
|
header[5] = headerType
|
||||||
|
binary.LittleEndian.PutUint64(header[6:14], granule)
|
||||||
|
header[26] = 1
|
||||||
|
return append(append(header, byte(len(packet))), packet...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||||
|
var picture bytes.Buffer
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
||||||
|
picture.WriteString(mime)
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
||||||
|
picture.Write(image)
|
||||||
|
return picture.Bytes()
|
||||||
|
}
|
||||||
@@ -9,14 +9,23 @@ import (
|
|||||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||||
|
|
||||||
|
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
||||||
|
// is superseded by a newer home/search request.
|
||||||
|
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
||||||
|
|
||||||
type cancelEntry struct {
|
type cancelEntry struct {
|
||||||
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
canceled bool
|
canceled bool
|
||||||
|
refs int
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cancelMu sync.Mutex
|
cancelMu sync.Mutex
|
||||||
cancelMap = make(map[string]*cancelEntry)
|
cancelMap = make(map[string]*cancelEntry)
|
||||||
|
|
||||||
|
extensionRequestCancelMu sync.Mutex
|
||||||
|
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDownloadCancel(itemID string) context.Context {
|
func initDownloadCancel(itemID string) context.Context {
|
||||||
@@ -27,10 +36,25 @@ func initDownloadCancel(itemID string) context.Context {
|
|||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
defer cancelMu.Unlock()
|
defer cancelMu.Unlock()
|
||||||
|
|
||||||
|
if entry, ok := cancelMap[itemID]; ok {
|
||||||
|
if entry.ctx == nil {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
entry.ctx = ctx
|
||||||
|
entry.cancel = cancel
|
||||||
|
if entry.canceled && entry.cancel != nil {
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.refs++
|
||||||
|
return entry.ctx
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancelMap[itemID] = &cancelEntry{
|
cancelMap[itemID] = &cancelEntry{
|
||||||
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
|
refs: 1,
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -73,6 +97,86 @@ func clearDownloadCancel(itemID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
delete(cancelMap, itemID)
|
if entry, ok := cancelMap[itemID]; ok {
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs <= 0 {
|
||||||
|
delete(cancelMap, itemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
cancelMu.Unlock()
|
cancelMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initExtensionRequestCancel(requestID string) context.Context {
|
||||||
|
if requestID == "" {
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionRequestCancelMu.Lock()
|
||||||
|
defer extensionRequestCancelMu.Unlock()
|
||||||
|
|
||||||
|
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||||
|
if entry.ctx == nil {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
entry.ctx = ctx
|
||||||
|
entry.cancel = cancel
|
||||||
|
if entry.canceled && entry.cancel != nil {
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.refs++
|
||||||
|
return entry.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
extensionRequestCancelMap[requestID] = &cancelEntry{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
canceled: false,
|
||||||
|
refs: 1,
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelExtensionRequest(requestID string) {
|
||||||
|
if requestID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionRequestCancelMu.Lock()
|
||||||
|
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||||
|
entry.canceled = true
|
||||||
|
if entry.cancel != nil {
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||||
|
}
|
||||||
|
extensionRequestCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExtensionRequestCancelled(requestID string) bool {
|
||||||
|
if requestID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionRequestCancelMu.Lock()
|
||||||
|
entry, ok := extensionRequestCancelMap[requestID]
|
||||||
|
canceled := ok && entry.canceled
|
||||||
|
extensionRequestCancelMu.Unlock()
|
||||||
|
return canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearExtensionRequestCancel(requestID string) {
|
||||||
|
if requestID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionRequestCancelMu.Lock()
|
||||||
|
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs <= 0 {
|
||||||
|
delete(extensionRequestCancelMap, requestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extensionRequestCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
|||||||
|
|
||||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
|
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -135,7 +137,7 @@ func upgradeQobuzCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
||||||
|
t.Fatalf("write index.js: %v", err)
|
||||||
|
}
|
||||||
|
return &loadedExtension{
|
||||||
|
ID: "coverage-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "coverage-ext",
|
||||||
|
Description: "Coverage extension",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Types: types,
|
||||||
|
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
|
||||||
|
SearchBehavior: &SearchBehaviorConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Placeholder: "Search coverage",
|
||||||
|
Primary: true,
|
||||||
|
Icon: "search",
|
||||||
|
},
|
||||||
|
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
|
||||||
|
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
|
||||||
|
PostProcessing: &PostProcessingConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Enabled: true,
|
||||||
|
SourceDir: dir,
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testExtensionJS = `
|
||||||
|
function track(id) {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
name: "Track " + id,
|
||||||
|
artists: "Artist",
|
||||||
|
albumName: "Album",
|
||||||
|
albumArtist: "Album Artist",
|
||||||
|
durationMs: 180000,
|
||||||
|
coverUrl: "https://example.test/cover.jpg",
|
||||||
|
releaseDate: "2026-05-04",
|
||||||
|
trackNumber: 1,
|
||||||
|
totalTracks: 10,
|
||||||
|
discNumber: 1,
|
||||||
|
totalDiscs: 1,
|
||||||
|
isrc: "USRC17607839",
|
||||||
|
itemType: "track",
|
||||||
|
albumType: "album",
|
||||||
|
tidalId: "tidal-1",
|
||||||
|
qobuzId: "qobuz-1",
|
||||||
|
deezerId: "deezer-1",
|
||||||
|
spotifyId: "spotify:track:1",
|
||||||
|
externalLinks: { tidal: "https://tidal.example/1" },
|
||||||
|
label: "Label",
|
||||||
|
copyright: "Copyright",
|
||||||
|
genre: "Pop",
|
||||||
|
composer: "Composer",
|
||||||
|
audioQuality: "FLAC 24-bit",
|
||||||
|
audioModes: "DOLBY_ATMOS"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerExtension({
|
||||||
|
searchTracks: function(query, limit) {
|
||||||
|
return { tracks: [track("search-1")], total: 1 };
|
||||||
|
},
|
||||||
|
customSearch: function(query, options) {
|
||||||
|
var t = track("custom-1");
|
||||||
|
t.name = "Custom " + query;
|
||||||
|
return [t];
|
||||||
|
},
|
||||||
|
getHomeFeed: function() {
|
||||||
|
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
|
||||||
|
},
|
||||||
|
getBrowseCategories: function() {
|
||||||
|
return [{ id: "cat-1", title: "Category" }];
|
||||||
|
},
|
||||||
|
getTrack: function(id) {
|
||||||
|
return track(id);
|
||||||
|
},
|
||||||
|
getAlbum: function(id) {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
name: "Album " + id,
|
||||||
|
artists: "Artist",
|
||||||
|
artistId: "artist-1",
|
||||||
|
coverUrl: "https://example.test/album.jpg",
|
||||||
|
releaseDate: "2026-05-04",
|
||||||
|
totalTracks: 1,
|
||||||
|
albumType: "album",
|
||||||
|
tracks: [track("album-track")]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getPlaylist: function(id) {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
name: "Playlist " + id,
|
||||||
|
artists: "Owner",
|
||||||
|
coverUrl: "https://example.test/playlist.jpg",
|
||||||
|
totalTracks: 1,
|
||||||
|
tracks: [track("playlist-track")]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getArtist: function(id) {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
name: "Artist",
|
||||||
|
imageUrl: "https://example.test/artist.jpg",
|
||||||
|
headerImage: "https://example.test/header.jpg",
|
||||||
|
listeners: 123,
|
||||||
|
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
|
||||||
|
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
|
||||||
|
topTracks: [track("top-track")]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enrichTrack: function(input) {
|
||||||
|
var t = track(input.id || "enriched");
|
||||||
|
t.name = "Enriched";
|
||||||
|
return t;
|
||||||
|
},
|
||||||
|
checkAvailability: function(isrc, name, artist, ids) {
|
||||||
|
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
|
||||||
|
},
|
||||||
|
getDownloadUrl: function(id, quality) {
|
||||||
|
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
|
||||||
|
},
|
||||||
|
download: function(id, quality, outputPath, onProgress) {
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath: "EXISTS:" + outputPath,
|
||||||
|
alreadyExists: false,
|
||||||
|
bitDepth: 24,
|
||||||
|
sampleRate: 96000,
|
||||||
|
title: "Downloaded",
|
||||||
|
artist: "Artist",
|
||||||
|
album: "Album",
|
||||||
|
albumArtist: "Album Artist",
|
||||||
|
trackNumber: 1,
|
||||||
|
totalTracks: 10,
|
||||||
|
discNumber: 1,
|
||||||
|
totalDiscs: 1,
|
||||||
|
releaseDate: "2026-05-04",
|
||||||
|
coverUrl: "https://example.test/cover.jpg",
|
||||||
|
isrc: "USRC17607839",
|
||||||
|
genre: "Pop",
|
||||||
|
label: "Label",
|
||||||
|
copyright: "Copyright",
|
||||||
|
composer: "Composer",
|
||||||
|
lyricsLrc: "[00:00.00]Hello",
|
||||||
|
decryptionKey: "001122",
|
||||||
|
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fetchLyrics: function(name, artist, album, duration) {
|
||||||
|
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
|
||||||
|
},
|
||||||
|
handleUrl: function(url) {
|
||||||
|
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
|
||||||
|
},
|
||||||
|
matchTrack: function(req) {
|
||||||
|
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
|
||||||
|
},
|
||||||
|
postProcess: function(path, req) {
|
||||||
|
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
|
||||||
|
},
|
||||||
|
postProcessV2: function(input, metadata, hookId) {
|
||||||
|
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
func mustReadFile(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read file: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildID3v23Tag(frames ...[]byte) []byte {
|
||||||
|
body := bytes.Join(frames, nil)
|
||||||
|
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
|
||||||
|
copy(header[6:10], syncsafeBytes(len(body)))
|
||||||
|
return append(header, body...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3TextFrame(id, value string) []byte {
|
||||||
|
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3CommentFrame(id, value string) []byte {
|
||||||
|
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||||
|
return id3v23Frame(id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3UserTextFrame(id, desc, value string) []byte {
|
||||||
|
payload := append([]byte{3}, []byte(desc)...)
|
||||||
|
payload = append(payload, 0)
|
||||||
|
payload = append(payload, []byte(value)...)
|
||||||
|
return id3v23Frame(id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3v23Frame(id string, payload []byte) []byte {
|
||||||
|
frame := make([]byte, 10+len(payload))
|
||||||
|
copy(frame[0:4], id)
|
||||||
|
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||||
|
copy(frame[10:], payload)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildID3v22Tag(frames ...[]byte) []byte {
|
||||||
|
body := bytes.Join(frames, nil)
|
||||||
|
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
|
||||||
|
copy(header[6:10], syncsafeBytes(len(body)))
|
||||||
|
return append(header, body...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3v22TextFrame(id, value string) []byte {
|
||||||
|
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3v22CommentFrame(id, value string) []byte {
|
||||||
|
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||||
|
return id3v22Frame(id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3v22Frame(id string, payload []byte) []byte {
|
||||||
|
frame := make([]byte, 6+len(payload))
|
||||||
|
copy(frame[0:3], id)
|
||||||
|
size := len(payload)
|
||||||
|
frame[3] = byte(size >> 16)
|
||||||
|
frame[4] = byte(size >> 8)
|
||||||
|
frame[5] = byte(size)
|
||||||
|
copy(frame[6:], payload)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncsafeBytes(size int) []byte {
|
||||||
|
return []byte{
|
||||||
|
byte((size >> 21) & 0x7f),
|
||||||
|
byte((size >> 14) & 0x7f),
|
||||||
|
byte((size >> 7) & 0x7f),
|
||||||
|
byte(size & 0x7f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
|
||||||
|
tag := make([]byte, 128)
|
||||||
|
copy(tag[0:3], "TAG")
|
||||||
|
copyPadded(tag[3:33], title)
|
||||||
|
copyPadded(tag[33:63], artist)
|
||||||
|
copyPadded(tag[63:93], album)
|
||||||
|
copyPadded(tag[93:97], year)
|
||||||
|
tag[125] = 0
|
||||||
|
tag[126] = track
|
||||||
|
tag[127] = genre
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPadded(dst []byte, value string) {
|
||||||
|
for i := range dst {
|
||||||
|
dst[i] = ' '
|
||||||
|
}
|
||||||
|
copy(dst, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
audioPath := filepath.Join(dir, "exports.wav")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatalf("write export audio: %v", err)
|
||||||
|
}
|
||||||
|
cuePath := filepath.Join(dir, "exports.cue")
|
||||||
|
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
|
||||||
|
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||||
|
t.Fatalf("write export cue: %v", err)
|
||||||
|
}
|
||||||
|
return cuePath, audioPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeJSONPath(path string) string {
|
||||||
|
data, _ := json.Marshal(path)
|
||||||
|
return strings.Trim(string(data), `"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeDeezerResponse(path, rawQuery string) string {
|
||||||
|
switch {
|
||||||
|
case path == "/2.0/search/track":
|
||||||
|
if strings.Contains(rawQuery, "MISSING") {
|
||||||
|
return `{"data":[]}`
|
||||||
|
}
|
||||||
|
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
|
||||||
|
case path == "/2.0/search/artist":
|
||||||
|
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
|
||||||
|
case path == "/2.0/search/album":
|
||||||
|
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
|
||||||
|
case path == "/2.0/search/playlist":
|
||||||
|
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
|
||||||
|
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
|
||||||
|
return fakeDeezerTrackJSON(101, true)
|
||||||
|
case path == "/2.0/track/102":
|
||||||
|
return fakeDeezerTrackJSON(102, true)
|
||||||
|
case path == "/2.0/track/isrc:MISSING":
|
||||||
|
return `{"id":0}`
|
||||||
|
case path == "/2.0/album/201":
|
||||||
|
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||||
|
case path == "/2.0/artist/301":
|
||||||
|
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
|
||||||
|
case path == "/2.0/artist/301/albums":
|
||||||
|
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
|
||||||
|
case path == "/2.0/artist/301/related":
|
||||||
|
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
|
||||||
|
case path == "/2.0/playlist/401":
|
||||||
|
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeDeezerTrackJSON(id int, withISRC bool) string {
|
||||||
|
isrc := ""
|
||||||
|
if withISRC {
|
||||||
|
isrc = `,"isrc":"USRC17607839"`
|
||||||
|
if id == 102 {
|
||||||
|
isrc = `,"isrc":"USRC17607840"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
out, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create extension package: %v", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(out)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
manifest := fmt.Sprintf(`{
|
||||||
|
"name": %q,
|
||||||
|
"displayName": %q,
|
||||||
|
"version": %q,
|
||||||
|
"description": "Packaged test extension",
|
||||||
|
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
|
||||||
|
"permissions": {"network": ["example.test"], "storage": true, "file": true},
|
||||||
|
"icon": "icon.png",
|
||||||
|
"settings": [{"key":"quality","type":"string","label":"Quality"}],
|
||||||
|
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
|
||||||
|
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
|
||||||
|
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
|
||||||
|
"trackMatching": {"customMatching": true},
|
||||||
|
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
|
||||||
|
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
|
||||||
|
"capabilities": {"homeFeed": true}
|
||||||
|
}`, name, name, version)
|
||||||
|
|
||||||
|
for fileName, content := range map[string]string{
|
||||||
|
"manifest.json": manifest,
|
||||||
|
"index.js": js,
|
||||||
|
"icon.png": "png",
|
||||||
|
} {
|
||||||
|
writer, err := zw.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip create %s: %v", fileName, err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("zip write %s: %v", fileName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fileName, content := range extraFiles {
|
||||||
|
writer, err := zw.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip create extra %s: %v", fileName, err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("zip write extra %s: %v", fileName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CrossExtensionShareResult struct {
|
||||||
|
ExtensionID string `json:"extension_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Found bool `json:"found"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
ItemName string `json:"item_name,omitempty"`
|
||||||
|
ItemArtists string `json:"item_artists,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var crossExtensionShareResultCache = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
entries map[string]string
|
||||||
|
order []string
|
||||||
|
}{
|
||||||
|
entries: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossExtensionShareResultCacheLimit = 128
|
||||||
|
|
||||||
|
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
SourceExtensionID string `json:"source_extension_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Artists = strings.TrimSpace(req.Artists)
|
||||||
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||||
|
if req.Name == "" {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = "album"
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := getExtensionManager().GetMetadataProviders()
|
||||||
|
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider == nil || provider.extension == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if provider.extension.ID == req.SourceExtensionID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
work = append(work, provider)
|
||||||
|
}
|
||||||
|
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||||
|
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.Name
|
||||||
|
if req.Artists != "" {
|
||||||
|
query += " " + req.Artists
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]CrossExtensionShareResult, len(work))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, provider := range work {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int, p *extensionProviderWrapper) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[index] = findCollectionForExtension(
|
||||||
|
p,
|
||||||
|
req.Type,
|
||||||
|
req.Name,
|
||||||
|
req.Artists,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
}(i, provider)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
data, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
response := string(data)
|
||||||
|
if crossExtensionShareResultsCacheable(results) {
|
||||||
|
setCrossExtensionShareCache(cacheKey, response)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||||
|
providerKeys := make([]string, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider == nil || provider.extension == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := provider.extension
|
||||||
|
displayName := ""
|
||||||
|
if ext.Manifest != nil {
|
||||||
|
displayName = ext.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
providerKeys = append(providerKeys, strings.Join([]string{
|
||||||
|
strings.TrimSpace(ext.ID),
|
||||||
|
strings.TrimSpace(displayName),
|
||||||
|
strings.TrimSpace(ext.SourceDir),
|
||||||
|
}, "\x1f"))
|
||||||
|
}
|
||||||
|
sort.Strings(providerKeys)
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
normalizeLooseTitle(itemType),
|
||||||
|
normalizeLooseTitle(name),
|
||||||
|
normalizeLooseArtistName(artists),
|
||||||
|
strings.TrimSpace(sourceExtensionID),
|
||||||
|
strings.Join(providerKeys, "\x1e"),
|
||||||
|
}, "\x1d")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCrossExtensionShareCache(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.RLock()
|
||||||
|
defer crossExtensionShareResultCache.RUnlock()
|
||||||
|
return crossExtensionShareResultCache.entries[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCrossExtensionShareCache(key string, value string) {
|
||||||
|
if key == "" || value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.Lock()
|
||||||
|
defer crossExtensionShareResultCache.Unlock()
|
||||||
|
|
||||||
|
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||||
|
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.entries[key] = value
|
||||||
|
|
||||||
|
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||||
|
oldest := crossExtensionShareResultCache.order[0]
|
||||||
|
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||||
|
delete(crossExtensionShareResultCache.entries, oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||||
|
if errText == "" ||
|
||||||
|
errText == "no results" ||
|
||||||
|
errText == "unsupported collection type" ||
|
||||||
|
strings.HasSuffix(errText, " not found") ||
|
||||||
|
strings.Contains(errText, "found without shareable link") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCollectionForExtension(
|
||||||
|
provider *extensionProviderWrapper,
|
||||||
|
itemType string,
|
||||||
|
name string,
|
||||||
|
artists string,
|
||||||
|
query string,
|
||||||
|
) CrossExtensionShareResult {
|
||||||
|
result := CrossExtensionShareResult{
|
||||||
|
ExtensionID: provider.extension.ID,
|
||||||
|
}
|
||||||
|
if provider.extension.Manifest != nil {
|
||||||
|
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
if result.DisplayName == "" {
|
||||||
|
result.DisplayName = provider.extension.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||||
|
result.Error = "no results"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *ExtTrackMetadata
|
||||||
|
switch itemType {
|
||||||
|
case "artist":
|
||||||
|
best = bestArtistTrack(searchResult.Tracks, name)
|
||||||
|
case "album":
|
||||||
|
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||||
|
default:
|
||||||
|
result.Error = "unsupported collection type"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
result.Error = itemType + " not found"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||||
|
if url == "" {
|
||||||
|
result.Error = itemType + " found without shareable link"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Found = true
|
||||||
|
result.URL = url
|
||||||
|
if itemType == "artist" {
|
||||||
|
result.ItemName = collectionArtistName(*best)
|
||||||
|
} else {
|
||||||
|
result.ItemName = collectionAlbumName(*best)
|
||||||
|
result.ItemArtists = best.Artists
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||||
|
filter := ""
|
||||||
|
switch itemType {
|
||||||
|
case "album":
|
||||||
|
filter = "albums"
|
||||||
|
case "artist":
|
||||||
|
filter = "artists"
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter != "" {
|
||||||
|
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||||
|
"filter": filter,
|
||||||
|
"limit": 10,
|
||||||
|
})
|
||||||
|
if err == nil && len(tracks) > 0 {
|
||||||
|
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.SearchTracks(query, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||||
|
targetAlbum := normalizeLooseTitle(albumName)
|
||||||
|
targetArtists := normalizeLooseArtistName(artists)
|
||||||
|
bestScore := 0
|
||||||
|
bestIndex := -1
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
track := tracks[i]
|
||||||
|
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||||
|
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
if isCollectionItemType(track, "album") {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
if album == targetAlbum {
|
||||||
|
score += 100
|
||||||
|
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIndex < 0 || bestScore < 50 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tracks[bestIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||||
|
targetArtist := normalizeLooseArtistName(artistName)
|
||||||
|
bestScore := 0
|
||||||
|
bestIndex := -1
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||||
|
score := 0
|
||||||
|
if isCollectionItemType(tracks[i], "artist") {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
if artist == targetArtist {
|
||||||
|
score += 100
|
||||||
|
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||||
|
score += 60
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIndex < 0 || bestScore < 60 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tracks[bestIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||||
|
if track == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemType == "album" {
|
||||||
|
if isCollectionItemType(*track, "album") {
|
||||||
|
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCollectionItemType(*track, "artist") {
|
||||||
|
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||||
|
if isCollectionItemType(track, "album") {
|
||||||
|
return track.Name
|
||||||
|
}
|
||||||
|
return track.AlbumName
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionArtistName(track ExtTrackMetadata) string {
|
||||||
|
if isCollectionItemType(track, "artist") {
|
||||||
|
return track.Name
|
||||||
|
}
|
||||||
|
return track.Artists
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||||
|
if isCollectionItemType(track, itemType) {
|
||||||
|
return track.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeShareURL(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||||
|
for key, value := range links {
|
||||||
|
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||||
|
if url := normalizeShareURL(value); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||||
|
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rawTemplate, ok := templates[itemType].(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||||
|
if rawTemplate == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripProviderPrefix(id string) string {
|
||||||
|
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||||
|
return id[index+1:]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Capabilities: map[string]interface{}{
|
||||||
|
"shareUrlTemplates": map[string]interface{}{
|
||||||
|
"album": "https://music.apple.com/us/album/{id}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "1440783617",
|
||||||
|
Name: "Nevermind",
|
||||||
|
Artists: "Nirvana",
|
||||||
|
ItemType: "album",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected album collection item to match")
|
||||||
|
}
|
||||||
|
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||||
|
t.Fatalf("album share URL = %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Capabilities: map[string]interface{}{
|
||||||
|
"shareUrlTemplates": map[string]interface{}{
|
||||||
|
"artist": "https://music.youtube.com/browse/{id}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||||
|
Name: "Nirvana",
|
||||||
|
ItemType: "artist",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := bestArtistTrack(tracks, "Nirvana")
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected artist collection item to match")
|
||||||
|
}
|
||||||
|
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||||
|
t.Fatalf("artist share URL = %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||||
|
apple := &extensionProviderWrapper{
|
||||||
|
extension: &loadedExtension{
|
||||||
|
ID: "apple",
|
||||||
|
SourceDir: "/extensions/apple",
|
||||||
|
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
qobuz := &extensionProviderWrapper{
|
||||||
|
extension: &loadedExtension{
|
||||||
|
ID: "qobuz",
|
||||||
|
SourceDir: "/extensions/qobuz",
|
||||||
|
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||||
|
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||||
|
cacheable := []CrossExtensionShareResult{
|
||||||
|
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||||
|
{ExtensionID: "qobuz", Error: "album not found"},
|
||||||
|
{ExtensionID: "tidal", Error: "no results"},
|
||||||
|
}
|
||||||
|
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||||
|
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||||
|
}
|
||||||
|
|
||||||
|
transient := []CrossExtensionShareResult{
|
||||||
|
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||||
|
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||||
|
}
|
||||||
|
if crossExtensionShareResultsCacheable(transient) {
|
||||||
|
t.Fatal("expected transient extension errors to skip cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCueParserEndToEnd(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(dir, "album.wav")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatalf("write audio: %v", err)
|
||||||
|
}
|
||||||
|
cuePath := filepath.Join(dir, "album.cue")
|
||||||
|
cue := "\ufeffREM GENRE \"Pop\"\n" +
|
||||||
|
"REM DATE 2026\n" +
|
||||||
|
"REM COMMENT \"comment\"\n" +
|
||||||
|
"REM COMPOSER \"Album Composer\"\n" +
|
||||||
|
"PERFORMER \"Album Artist\"\n" +
|
||||||
|
"TITLE \"Album Title\"\n" +
|
||||||
|
"FILE \"album.wav\" WAVE\n" +
|
||||||
|
" TRACK 01 AUDIO\n" +
|
||||||
|
" TITLE \"First\"\n" +
|
||||||
|
" PERFORMER \"Track Artist\"\n" +
|
||||||
|
" ISRC USRC17607839\n" +
|
||||||
|
" INDEX 01 00:00:00\n" +
|
||||||
|
" TRACK 02 AUDIO\n" +
|
||||||
|
" TITLE \"Second\"\n" +
|
||||||
|
" SONGWRITER \"Track Composer\"\n" +
|
||||||
|
" INDEX 00 03:00:00\n" +
|
||||||
|
" INDEX 01 03:05:00\n"
|
||||||
|
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||||
|
t.Fatalf("write cue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCueFile: %v", err)
|
||||||
|
}
|
||||||
|
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
|
||||||
|
t.Fatalf("sheet = %#v", sheet)
|
||||||
|
}
|
||||||
|
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
|
||||||
|
t.Fatalf("timestamp = %f", got)
|
||||||
|
}
|
||||||
|
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
|
||||||
|
t.Fatalf("format timestamp = %q", got)
|
||||||
|
}
|
||||||
|
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
|
||||||
|
t.Fatalf("unquote = %q", got)
|
||||||
|
}
|
||||||
|
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
|
||||||
|
if fileName != "unquoted album.flac" || fileType != "FLAC" {
|
||||||
|
t.Fatalf("file line = %q/%q", fileName, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
|
||||||
|
t.Fatalf("resolved = %q want %q", resolved, audioPath)
|
||||||
|
}
|
||||||
|
info, err := BuildCueSplitInfo(cuePath, sheet, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildCueSplitInfo: %v", err)
|
||||||
|
}
|
||||||
|
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
|
||||||
|
t.Fatalf("split info = %#v", info.Tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonText, err := ParseCueFileJSON(cuePath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCueFileJSON: %v", err)
|
||||||
|
}
|
||||||
|
var decoded CueSplitInfo
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
|
||||||
|
t.Fatalf("decode cue json: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.AudioPath != audioPath {
|
||||||
|
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
|
||||||
|
t.Fatalf("scan results = %#v", results)
|
||||||
|
}
|
||||||
|
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
|
||||||
|
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
|
||||||
|
t.Fatal("expected missing cue error")
|
||||||
|
}
|
||||||
|
emptyCue := filepath.Join(dir, "empty.cue")
|
||||||
|
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ParseCueFile(emptyCue); err == nil {
|
||||||
|
t.Fatal("expected no tracks error")
|
||||||
|
}
|
||||||
|
missingDir := t.TempDir()
|
||||||
|
missingCuePath := filepath.Join(missingDir, "missing.cue")
|
||||||
|
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
|
||||||
|
t.Fatal("expected missing audio error")
|
||||||
|
}
|
||||||
|
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
|
||||||
|
t.Fatal("expected nil sheet error")
|
||||||
|
}
|
||||||
|
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
|
||||||
|
t.Fatal("expected nil scan sheet error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "song.flac")
|
||||||
|
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
|
||||||
|
idx.Add("usrc17607839", filePath)
|
||||||
|
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||||
|
t.Fatalf("lookup = %q/%v", got, ok)
|
||||||
|
}
|
||||||
|
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
|
||||||
|
t.Fatalf("Lookup = %q/%v", got, err)
|
||||||
|
}
|
||||||
|
idx.remove("usrc17607839")
|
||||||
|
if _, ok := idx.lookup("usrc17607839"); ok {
|
||||||
|
t.Fatal("expected removed ISRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
isrcIndexCache[dir] = idx
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
defer InvalidateISRCCache(dir)
|
||||||
|
|
||||||
|
AddToISRCIndex(dir, "USRC17607839", filePath)
|
||||||
|
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
|
||||||
|
t.Fatalf("CheckISRCExists = %q/%v", found, err)
|
||||||
|
}
|
||||||
|
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
|
||||||
|
t.Fatal("unexpected file existence result")
|
||||||
|
}
|
||||||
|
|
||||||
|
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
|
||||||
|
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckFilesExistParallel: %v", err)
|
||||||
|
}
|
||||||
|
var results []FileExistenceResult
|
||||||
|
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
|
||||||
|
t.Fatalf("decode results: %v", err)
|
||||||
|
}
|
||||||
|
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
|
||||||
|
t.Fatalf("results = %#v", results)
|
||||||
|
}
|
||||||
|
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
|
||||||
|
t.Fatal("expected invalid json error")
|
||||||
|
}
|
||||||
|
if err := PreBuildISRCIndex(""); err == nil {
|
||||||
|
t.Fatal("expected empty dir error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
for _, ext := range commonExts {
|
for _, ext := range commonExts {
|
||||||
candidate = filepath.Join(cueDir, baseName+ext)
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -783,7 +784,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
// not include this field. Albums whose track count is already known (non-zero)
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
// are skipped.
|
// are skipped.
|
||||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
// Find albums that need track counts
|
|
||||||
type indexedID struct {
|
type indexedID struct {
|
||||||
idx int
|
idx int
|
||||||
albumID string
|
albumID string
|
||||||
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
if !isDeezerRetryableError(err) {
|
||||||
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "EOF") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1286,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerAPIError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *deezerAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDeezerRetryableError(err error) bool {
|
||||||
|
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var apiErr *deezerAPIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
||||||
|
client := &DeezerClient{
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||||
|
status := http.StatusOK
|
||||||
|
if body == "" {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
body = `{"error":"missing"}`
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
searchCache: map[string]*cacheEntry{},
|
||||||
|
albumCache: map[string]*cacheEntry{},
|
||||||
|
artistCache: map[string]*cacheEntry{},
|
||||||
|
isrcCache: map[string]string{},
|
||||||
|
cacheCleanupInterval: time.Millisecond,
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
|
||||||
|
t.Fatalf("search = %#v", search)
|
||||||
|
}
|
||||||
|
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||||
|
if err != nil || cached != search {
|
||||||
|
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
|
||||||
|
}
|
||||||
|
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
|
||||||
|
t.Fatalf("filtered search = %#v/%v", filtered, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := client.GetTrack(ctx, "101")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTrack: %v", err)
|
||||||
|
}
|
||||||
|
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
|
||||||
|
t.Fatalf("track = %#v", track)
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := client.GetAlbum(ctx, "201")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbum: %v", err)
|
||||||
|
}
|
||||||
|
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
|
||||||
|
t.Fatalf("album = %#v", album)
|
||||||
|
}
|
||||||
|
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
|
||||||
|
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := client.GetArtist(ctx, "301")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetArtist: %v", err)
|
||||||
|
}
|
||||||
|
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
|
||||||
|
t.Fatalf("artist = %#v", artist)
|
||||||
|
}
|
||||||
|
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
|
||||||
|
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRelatedArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(related) != 1 || related[0].ID != "deezer:302" {
|
||||||
|
t.Fatalf("related = %#v", related)
|
||||||
|
}
|
||||||
|
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
||||||
|
t.Fatal("expected invalid related artist ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist, err := client.GetPlaylist(ctx, "401")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPlaylist: %v", err)
|
||||||
|
}
|
||||||
|
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
|
||||||
|
t.Fatalf("playlist = %#v", playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchByISRC: %v", err)
|
||||||
|
}
|
||||||
|
if byISRC.SpotifyID != "deezer:101" {
|
||||||
|
t.Fatalf("by ISRC = %#v", byISRC)
|
||||||
|
}
|
||||||
|
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
|
||||||
|
t.Fatal("expected missing ISRC error")
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc, err := client.GetTrackISRC(ctx, "102")
|
||||||
|
if err != nil || isrc != "USRC17607840" {
|
||||||
|
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
|
||||||
|
}
|
||||||
|
albumID, err := client.GetTrackAlbumID(ctx, "101")
|
||||||
|
if err != nil || albumID != "201" {
|
||||||
|
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
|
||||||
|
}
|
||||||
|
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
|
||||||
|
t.Fatalf("extended = %#v", extended)
|
||||||
|
}
|
||||||
|
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
|
||||||
|
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
|
||||||
|
}
|
||||||
|
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
|
||||||
|
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
|
||||||
|
}
|
||||||
|
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
|
||||||
|
t.Fatal("expected empty ISRC metadata error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
|
||||||
|
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
|
||||||
|
}
|
||||||
|
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
|
||||||
|
t.Fatal("expected non-Deezer URL error")
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cacheMu.Lock()
|
||||||
|
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
|
||||||
|
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
|
||||||
|
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
|
||||||
|
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
|
||||||
|
client.trimCacheEntriesLocked(client.searchCache, 1)
|
||||||
|
client.isrcCache["1"] = "A"
|
||||||
|
client.isrcCache["2"] = "B"
|
||||||
|
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
|
||||||
|
client.cacheMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionPackageExportWrappers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
extensionsDir := filepath.Join(dir, "extensions")
|
||||||
|
dataDir := filepath.Join(dir, "data")
|
||||||
|
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
CleanupExtensions()
|
||||||
|
defer CleanupExtensions()
|
||||||
|
|
||||||
|
js := `
|
||||||
|
registerExtension({
|
||||||
|
initialize: function(settings) { this.settings = settings || {}; },
|
||||||
|
cleanup: function() {},
|
||||||
|
doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; },
|
||||||
|
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||||
|
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||||
|
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||||
|
});
|
||||||
|
`
|
||||||
|
pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext")
|
||||||
|
pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext")
|
||||||
|
createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil)
|
||||||
|
createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil)
|
||||||
|
|
||||||
|
loadedJSON, err := LoadExtensionFromPath(pkgV1)
|
||||||
|
if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") {
|
||||||
|
t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err)
|
||||||
|
}
|
||||||
|
if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") {
|
||||||
|
t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err)
|
||||||
|
}
|
||||||
|
if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil {
|
||||||
|
t.Fatalf("SetExtensionEnabledByID true: %v", err)
|
||||||
|
}
|
||||||
|
if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") {
|
||||||
|
t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err)
|
||||||
|
}
|
||||||
|
if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||||
|
t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err)
|
||||||
|
}
|
||||||
|
if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") {
|
||||||
|
t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err)
|
||||||
|
}
|
||||||
|
if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil {
|
||||||
|
t.Fatalf("SetExtensionEnabledByID false: %v", err)
|
||||||
|
}
|
||||||
|
if err := UnloadExtensionByID("wrapper-ext"); err != nil {
|
||||||
|
t.Fatalf("UnloadExtensionByID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext")
|
||||||
|
if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil {
|
||||||
|
t.Fatalf("create directory extension: %v", err)
|
||||||
|
}
|
||||||
|
if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") {
|
||||||
|
t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err)
|
||||||
|
}
|
||||||
|
if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil {
|
||||||
|
t.Fatalf("RemoveExtensionByID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDirectoryExtension(dir, name, version string) error {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(dir, "sidecar.mp3")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||||
|
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
||||||
|
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||||
|
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
|
||||||
|
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
|
||||||
|
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(dir, "lyrics.lrc")
|
||||||
|
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
|
||||||
|
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
|
||||||
|
}
|
||||||
|
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
|
||||||
|
t.Fatalf("saved lyrics = %q", data)
|
||||||
|
}
|
||||||
|
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||||
|
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||||
|
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||||
|
origClient := globalSongLinkClient
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
origSearchByISRC := songLinkSearchByISRC
|
||||||
|
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
|
||||||
|
defer func() {
|
||||||
|
globalSongLinkClient = origClient
|
||||||
|
songLinkRetryConfig = origRetryConfig
|
||||||
|
songLinkSearchByISRC = origSearchByISRC
|
||||||
|
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
|
||||||
|
SetSongLinkNetworkOptions(false, false)
|
||||||
|
}()
|
||||||
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
|
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||||
|
}
|
||||||
|
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
var body string
|
||||||
|
if req.URL.Host == "api.zarz.moe" {
|
||||||
|
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
|
||||||
|
} else if req.URL.Host == "api.song.link" {
|
||||||
|
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
songLinkClientOnce.Do(func() {})
|
||||||
|
|
||||||
|
SetSongLinkNetworkOptions(true, true)
|
||||||
|
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||||
|
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
|
||||||
|
}
|
||||||
|
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
|
||||||
|
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
|
||||||
|
}
|
||||||
|
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
|
||||||
|
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
|
||||||
|
}
|
||||||
|
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
|
||||||
|
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
|
||||||
|
}
|
||||||
|
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
|
||||||
|
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
|
||||||
|
}
|
||||||
|
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
|
||||||
|
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
|
||||||
|
}
|
||||||
|
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
|
||||||
|
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
|
||||||
|
}
|
||||||
|
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
|
||||||
|
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
|
||||||
|
}
|
||||||
|
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
|
||||||
|
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
|
||||||
|
}
|
||||||
|
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
|
||||||
|
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
|
||||||
|
}
|
||||||
|
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
|
||||||
|
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
|
||||||
|
}
|
||||||
|
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
|
||||||
|
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
|
||||||
|
}
|
||||||
|
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
|
||||||
|
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
|
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
|
||||||
|
}
|
||||||
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
|
||||||
|
}
|
||||||
|
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||||
|
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
|
||||||
|
}
|
||||||
|
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
|
||||||
|
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerClient = &DeezerClient{
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||||
|
if body == "" {
|
||||||
|
body = `{"error":"missing"}`
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||||
|
})},
|
||||||
|
searchCache: map[string]*cacheEntry{},
|
||||||
|
albumCache: map[string]*cacheEntry{},
|
||||||
|
artistCache: map[string]*cacheEntry{},
|
||||||
|
isrcCache: map[string]string{},
|
||||||
|
cacheCleanupInterval: time.Hour,
|
||||||
|
}
|
||||||
|
deezerClientOnce.Do(func() {})
|
||||||
|
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
|
||||||
|
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
|
||||||
|
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||||
|
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||||
|
if got != "rate_limit" {
|
||||||
|
t.Fatalf("expected rate_limit, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("errorResponse returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response DownloadResponse
|
||||||
|
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||||
|
t.Fatalf("invalid response JSON: %v", err)
|
||||||
|
}
|
||||||
|
if response.ErrorType != "rate_limit" {
|
||||||
|
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"HTTP 401 for /tickets",
|
||||||
|
"HTTP status 428: precondition required",
|
||||||
|
"Verification required",
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||||
|
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
CleanupExtensions()
|
||||||
|
defer CleanupExtensions()
|
||||||
|
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||||
|
ext.ID = "deezer"
|
||||||
|
ext.Manifest.Name = "deezer"
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonText, "album-track") {
|
||||||
|
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(dir, "data")
|
||||||
|
extensionsDir := filepath.Join(dir, "extensions")
|
||||||
|
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
if manager.extensions == nil {
|
||||||
|
manager.extensions = map[string]*loadedExtension{}
|
||||||
|
}
|
||||||
|
manager.extensions[ext.ID] = ext
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
delete(manager.extensions, ext.ID)
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||||
|
t.Fatalf("DownloadTrack = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
|
||||||
|
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
|
||||||
|
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||||
|
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
InitItemProgress("item-1")
|
||||||
|
FinishItemProgress("item-1")
|
||||||
|
ClearItemProgress("item-1")
|
||||||
|
CancelDownload("item-1")
|
||||||
|
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||||
|
t.Fatal("expected progress JSON")
|
||||||
|
}
|
||||||
|
CleanupConnections()
|
||||||
|
|
||||||
|
cuePath, audioPath := writeExportCueFixture(t, dir)
|
||||||
|
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
|
||||||
|
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
|
||||||
|
} else {
|
||||||
|
var parsed CueSplitInfo
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||||
|
t.Fatalf("decode ParseCueSheet: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.AudioPath != audioPath {
|
||||||
|
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||||
|
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||||
|
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apePath := filepath.Join(dir, "edit.ape")
|
||||||
|
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
|
||||||
|
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
|
||||||
|
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
||||||
|
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
|
||||||
|
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
|
||||||
|
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
|
||||||
|
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
||||||
|
t.Fatal("expected invalid metadata JSON")
|
||||||
|
}
|
||||||
|
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
|
||||||
|
t.Fatal("expected replaygain-only fields")
|
||||||
|
}
|
||||||
|
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
|
||||||
|
t.Fatal("expected non-replaygain field rejection")
|
||||||
|
}
|
||||||
|
|
||||||
|
AllowDownloadDir(dir)
|
||||||
|
if err := SetDownloadDirectory(dir); err != nil {
|
||||||
|
t.Fatalf("SetDownloadDirectory: %v", err)
|
||||||
|
}
|
||||||
|
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
|
||||||
|
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
|
||||||
|
}
|
||||||
|
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
||||||
|
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
||||||
|
}
|
||||||
|
_ = PreBuildDuplicateIndex(dir)
|
||||||
|
InvalidateDuplicateIndex(dir)
|
||||||
|
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
|
||||||
|
t.Fatalf("BuildFilename = %q/%v", filename, err)
|
||||||
|
}
|
||||||
|
if _, err := BuildFilename("{title}", `not-json`); err == nil {
|
||||||
|
t.Fatal("expected BuildFilename JSON error")
|
||||||
|
}
|
||||||
|
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
|
||||||
|
t.Fatalf("SanitizeFilename = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
|
||||||
|
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
|
||||||
|
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
|
||||||
|
}
|
||||||
|
if GetTrackCacheSize() != 0 {
|
||||||
|
t.Fatal("expected empty track cache")
|
||||||
|
}
|
||||||
|
ClearTrackIDCache()
|
||||||
|
|
||||||
|
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
|
||||||
|
t.Fatalf("SetLyricsProvidersJSON: %v", err)
|
||||||
|
}
|
||||||
|
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
|
||||||
|
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
|
||||||
|
}
|
||||||
|
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
|
||||||
|
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
|
||||||
|
}
|
||||||
|
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
|
||||||
|
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
|
||||||
|
}
|
||||||
|
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
|
||||||
|
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||||
|
t.Fatalf("SetProviderPriorityJSON: %v", err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||||
|
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
|
||||||
|
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||||
|
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||||
|
t.Fatalf("reset extension fallback IDs: %v", err)
|
||||||
|
}
|
||||||
|
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||||
|
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||||
|
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
|
||||||
|
t.Fatalf("SetExtensionSettingsJSON: %v", err)
|
||||||
|
}
|
||||||
|
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
|
||||||
|
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
|
||||||
|
}
|
||||||
|
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
|
||||||
|
t.Fatal("expected settings JSON error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||||
|
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||||
|
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
|
||||||
|
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
for _, resourceType := range []string{"album", "playlist", "artist"} {
|
||||||
|
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
|
||||||
|
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
|
||||||
|
t.Fatal("expected empty provider ID error")
|
||||||
|
}
|
||||||
|
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
|
||||||
|
t.Fatal("expected unsupported provider type")
|
||||||
|
}
|
||||||
|
if firstNonEmptyTrimmed(" ", " value ") != "value" {
|
||||||
|
t.Fatal("expected first trimmed value")
|
||||||
|
}
|
||||||
|
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
|
||||||
|
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||||
|
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
|
||||||
|
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
|
||||||
|
}
|
||||||
|
|
||||||
|
SetExtensionAuthCodeByID(ext.ID, "code")
|
||||||
|
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
|
||||||
|
if !IsExtensionAuthenticatedByID(ext.ID) {
|
||||||
|
t.Fatal("expected authenticated extension")
|
||||||
|
}
|
||||||
|
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
|
||||||
|
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
|
||||||
|
}
|
||||||
|
ClearExtensionPendingAuthByID(ext.ID)
|
||||||
|
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
|
||||||
|
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
|
||||||
|
ffmpegCommandsMu.Unlock()
|
||||||
|
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
|
||||||
|
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
|
||||||
|
}
|
||||||
|
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
|
||||||
|
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
|
||||||
|
}
|
||||||
|
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
|
||||||
|
ClearFFmpegCommand("cmd-1")
|
||||||
|
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
|
||||||
|
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
|
||||||
|
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
|
||||||
|
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
|
||||||
|
}
|
||||||
|
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
|
||||||
|
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerClient = &DeezerClient{
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||||
|
status := http.StatusOK
|
||||||
|
if body == "" {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
body = `{"error":"missing"}`
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||||
|
})},
|
||||||
|
searchCache: map[string]*cacheEntry{},
|
||||||
|
albumCache: map[string]*cacheEntry{},
|
||||||
|
artistCache: map[string]*cacheEntry{},
|
||||||
|
isrcCache: map[string]string{},
|
||||||
|
cacheCleanupInterval: time.Hour,
|
||||||
|
}
|
||||||
|
deezerClientOnce.Do(func() {})
|
||||||
|
for _, item := range []struct {
|
||||||
|
typ string
|
||||||
|
id string
|
||||||
|
}{
|
||||||
|
{"track", "101"},
|
||||||
|
{"album", "201"},
|
||||||
|
{"artist", "301"},
|
||||||
|
{"playlist", "401"},
|
||||||
|
} {
|
||||||
|
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
|
||||||
|
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
||||||
|
t.Fatal("expected unsupported Deezer metadata type")
|
||||||
|
}
|
||||||
|
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
||||||
|
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
||||||
|
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if _, err := GetDeezerExtendedMetadata(""); err == nil {
|
||||||
|
t.Fatal("expected empty Deezer metadata ID error")
|
||||||
|
}
|
||||||
|
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||||
|
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||||
|
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
||||||
|
if err != nil || !strings.Contains(customJSON, "Custom needle") {
|
||||||
|
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
|
||||||
|
}
|
||||||
|
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
||||||
|
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
||||||
|
}
|
||||||
|
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
|
||||||
|
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
|
||||||
|
}
|
||||||
|
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
||||||
|
t.Fatalf("FindURLHandlerJSON = %q", found)
|
||||||
|
}
|
||||||
|
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
|
||||||
|
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
|
||||||
|
}
|
||||||
|
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
||||||
|
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
||||||
|
}
|
||||||
|
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
|
||||||
|
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
|
||||||
|
}
|
||||||
|
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
||||||
|
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
||||||
|
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
||||||
|
}
|
||||||
|
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
|
||||||
|
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
|
||||||
|
}
|
||||||
|
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||||
|
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
||||||
|
}
|
||||||
|
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||||
|
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
||||||
|
}
|
||||||
|
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
|
||||||
|
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
|
||||||
|
}
|
||||||
|
CancelExtensionRequestJSON("req-home")
|
||||||
|
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
||||||
|
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
||||||
|
}
|
||||||
|
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||||
|
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
||||||
|
}
|
||||||
|
store := getExtensionStore()
|
||||||
|
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
||||||
|
ID: "coverage-ext",
|
||||||
|
Name: "coverage-ext",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Description: "Coverage",
|
||||||
|
Category: CategoryMetadata,
|
||||||
|
Tags: []string{"metadata"},
|
||||||
|
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
||||||
|
}}}
|
||||||
|
store.cacheTime = time.Now()
|
||||||
|
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
||||||
|
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
||||||
|
}
|
||||||
|
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||||
|
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||||
|
}
|
||||||
|
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||||
|
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||||
|
}
|
||||||
|
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||||
|
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||||
|
}
|
||||||
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.spotiflac-ext",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||||
|
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||||
|
}
|
||||||
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||||
|
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
|
||||||
|
}
|
||||||
|
if _, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
" ",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("expected invalid extension id")
|
||||||
|
}
|
||||||
|
if err := ClearStoreCacheJSON(); err != nil {
|
||||||
|
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
||||||
|
}
|
||||||
|
if err := ClearStoreRegistryURLJSON(); err != nil {
|
||||||
|
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
||||||
|
libraryDir := filepath.Join(dir, "library")
|
||||||
|
if err := os.MkdirAll(libraryDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||||
|
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
|
||||||
|
}
|
||||||
|
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||||
|
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
|
||||||
|
}
|
||||||
|
snapshotPath := filepath.Join(dir, "snapshot.json")
|
||||||
|
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||||
|
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
|
||||||
|
}
|
||||||
|
if GetLibraryScanProgressJSON() == "" {
|
||||||
|
t.Fatal("expected scan progress JSON")
|
||||||
|
}
|
||||||
|
CancelLibraryScanJSON()
|
||||||
|
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
|
||||||
|
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
|
||||||
|
}
|
||||||
|
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
|
||||||
|
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
|
||||||
|
}
|
||||||
|
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
|
||||||
|
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||||
original := GetExtensionFallbackProviderIDs()
|
original := GetExtensionFallbackProviderIDs()
|
||||||
@@ -161,6 +165,184 @@ func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
||||||
|
got := formatMusicBrainzGenre([]musicBrainzTag{
|
||||||
|
{Name: "art pop", Count: 3},
|
||||||
|
{Name: "pop", Count: 8},
|
||||||
|
{Name: "dance pop", Count: 5},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got != "Pop" {
|
||||||
|
t.Fatalf("genre = %q, want %q", got, "Pop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) {
|
||||||
|
releases := []musicBrainzRelease{
|
||||||
|
{
|
||||||
|
Title: "Other Album",
|
||||||
|
ArtistCredit: []musicBrainzArtistCredit{
|
||||||
|
{Name: "Wrong Artist"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Target Album",
|
||||||
|
ArtistCredit: []musicBrainzArtistCredit{
|
||||||
|
{Name: "Artist A", JoinPhrase: " & "},
|
||||||
|
{Name: "Artist B"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := selectMusicBrainzAlbumArtist(releases, "Target Album")
|
||||||
|
if got != "Artist A & Artist B" {
|
||||||
|
t.Fatalf("album artist = %q, want matching release artist credit", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) {
|
||||||
|
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||||
|
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||||
|
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||||
|
defer func() {
|
||||||
|
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||||
|
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||||
|
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||||
|
}()
|
||||||
|
|
||||||
|
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
return &AlbumExtendedMetadata{}, nil
|
||||||
|
}
|
||||||
|
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||||
|
return "", fmt.Errorf("no genre")
|
||||||
|
}
|
||||||
|
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||||
|
if isrc != "TESTISRC" || albumName != "Target Album" {
|
||||||
|
t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName)
|
||||||
|
}
|
||||||
|
return "MusicBrainz Album Artist", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
ISRC: "TESTISRC",
|
||||||
|
ArtistName: "Track Artist",
|
||||||
|
AlbumName: "Target Album",
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
|
if req.AlbumArtist != "MusicBrainz Album Artist" {
|
||||||
|
t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) {
|
||||||
|
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||||
|
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||||
|
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||||
|
defer func() {
|
||||||
|
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||||
|
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||||
|
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||||
|
}()
|
||||||
|
|
||||||
|
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
return &AlbumExtendedMetadata{}, nil
|
||||||
|
}
|
||||||
|
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||||
|
return "", fmt.Errorf("no genre")
|
||||||
|
}
|
||||||
|
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||||
|
return "", fmt.Errorf("no album artist")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
ISRC: "TESTISRC",
|
||||||
|
ArtistName: "Track Artist",
|
||||||
|
AlbumName: "Target Album",
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
|
if req.AlbumArtist != "" {
|
||||||
|
t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
||||||
|
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||||
|
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||||
|
defer func() {
|
||||||
|
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||||
|
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||||
|
}()
|
||||||
|
|
||||||
|
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||||
|
if isrc != "TEST123" {
|
||||||
|
t.Fatalf("unexpected isrc: %q", isrc)
|
||||||
|
}
|
||||||
|
return "Alternative Rock", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
genre := ""
|
||||||
|
label := ""
|
||||||
|
copyright := ""
|
||||||
|
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, ©right)
|
||||||
|
|
||||||
|
if genre != "Alternative Rock" {
|
||||||
|
t.Fatalf("genre = %q, want fallback genre", genre)
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
t.Fatalf("label = %q, want empty", label)
|
||||||
|
}
|
||||||
|
if copyright != "" {
|
||||||
|
t.Fatalf("copyright = %q, want empty", copyright)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
|
||||||
|
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||||
|
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||||
|
defer func() {
|
||||||
|
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||||
|
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||||
|
}()
|
||||||
|
|
||||||
|
musicBrainzCalled := false
|
||||||
|
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
return &AlbumExtendedMetadata{
|
||||||
|
Genre: "Synthpop",
|
||||||
|
Label: "EMI",
|
||||||
|
Copyright: "(C) Test",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||||
|
musicBrainzCalled = true
|
||||||
|
return "Rock", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
genre := ""
|
||||||
|
label := ""
|
||||||
|
copyright := ""
|
||||||
|
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, ©right)
|
||||||
|
|
||||||
|
if genre != "Synthpop" {
|
||||||
|
t.Fatalf("genre = %q, want Deezer genre", genre)
|
||||||
|
}
|
||||||
|
if label != "EMI" {
|
||||||
|
t.Fatalf("label = %q, want Deezer label", label)
|
||||||
|
}
|
||||||
|
if copyright != "(C) Test" {
|
||||||
|
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
|
||||||
|
}
|
||||||
|
if musicBrainzCalled {
|
||||||
|
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
SpotifyID: "spotify-track-id",
|
SpotifyID: "spotify-track-id",
|
||||||
@@ -225,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "wrong-rich-metadata",
|
||||||
|
Name: "Different Song",
|
||||||
|
Artists: "Different Artist",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
TrackNumber: 4,
|
||||||
|
DiscNumber: 1,
|
||||||
|
ISRC: "WRONG1234567",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||||
|
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
DurationMs: 999999000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "same-isrc",
|
||||||
|
Name: "Different Song",
|
||||||
|
Artists: "Different Artist",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected exact ISRC candidate to be selected")
|
||||||
|
}
|
||||||
|
if best.ID != "same-isrc" {
|
||||||
|
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Unknown Title",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "album-match",
|
||||||
|
Name: "Sign of the Times",
|
||||||
|
Artists: "Harry Styles",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
||||||
|
}
|
||||||
|
if best.ID != "album-match" {
|
||||||
|
t.Fatalf("selected track = %q, want album-match", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
TrackName: "Song",
|
TrackName: "Song",
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
extensionHealthDefaultTimeout = 4 * time.Second
|
||||||
|
extensionHealthMaxBodyBytes = 64 * 1024
|
||||||
|
extensionHealthDefaultCache = 10 * time.Minute
|
||||||
|
extensionHealthMinCache = 60 * time.Second
|
||||||
|
extensionHealthUnknownCache = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionHealthResult struct {
|
||||||
|
ExtensionID string `json:"extension_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
|
Checks []ExtensionHealthCheckResult `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionHealthCheckResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
ServiceKey string `json:"service_key,omitempty"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
HTTPStatus int `json:"http_status,omitempty"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CheckedAt string `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedExtensionHealthResult struct {
|
||||||
|
result ExtensionHealthResult
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
extensionHealthCacheMu sync.Mutex
|
||||||
|
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
|
bytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return CheckExtensionHealth(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(ext.ID)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return CheckExtensionHealth(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
cached, ok := extensionHealthCache[cacheKey]
|
||||||
|
if ok && now.Before(cached.expiresAt) {
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
return cached.result
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(ext.ID)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||||
|
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||||
|
ttl = extensionHealthUnknownCache
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||||
|
result: result,
|
||||||
|
expiresAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
result := ExtensionHealthResult{
|
||||||
|
ExtensionID: "",
|
||||||
|
Status: "unsupported",
|
||||||
|
CheckedAt: now,
|
||||||
|
Checks: []ExtensionHealthCheckResult{},
|
||||||
|
}
|
||||||
|
if ext == nil || ext.Manifest == nil {
|
||||||
|
result.Status = "offline"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ExtensionID = ext.ID
|
||||||
|
checks := ext.Manifest.ServiceHealth
|
||||||
|
if len(checks) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Status = "online"
|
||||||
|
for _, check := range checks {
|
||||||
|
checkResult := runExtensionHealthCheck(ext.Manifest, check)
|
||||||
|
result.Checks = append(result.Checks, checkResult)
|
||||||
|
|
||||||
|
switch checkResult.Status {
|
||||||
|
case "offline":
|
||||||
|
if check.Required {
|
||||||
|
result.Status = "offline"
|
||||||
|
} else if result.Status == "online" {
|
||||||
|
result.Status = "degraded"
|
||||||
|
}
|
||||||
|
case "degraded":
|
||||||
|
if result.Status == "online" {
|
||||||
|
result.Status = "degraded"
|
||||||
|
}
|
||||||
|
case "unknown":
|
||||||
|
if result.Status == "online" {
|
||||||
|
result.Status = "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||||
|
ttl := extensionHealthDefaultCache
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.CacheTTLSeconds <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||||
|
if checkTTL < extensionHealthMinCache {
|
||||||
|
checkTTL = extensionHealthMinCache
|
||||||
|
}
|
||||||
|
if checkTTL < ttl {
|
||||||
|
ttl = checkTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||||
|
if method == "" {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
result := ExtensionHealthCheckResult{
|
||||||
|
ID: check.ID,
|
||||||
|
Label: check.Label,
|
||||||
|
URL: check.URL,
|
||||||
|
Method: method,
|
||||||
|
ServiceKey: strings.TrimSpace(check.ServiceKey),
|
||||||
|
Required: check.Required,
|
||||||
|
Status: "unknown",
|
||||||
|
CheckedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(check.URL)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = fmt.Sprintf("invalid health URL: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = "health check must use https"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = "health check URL hostname is required"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if isPrivateIP(host) {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = "private/local health check host is not allowed"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if manifest == nil || !manifest.IsDomainAllowed(host) {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if method != http.MethodGet && method != http.MethodHead {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = "health check method must be GET or HEAD"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := extensionHealthDefaultTimeout
|
||||||
|
if check.TimeoutMs > 0 {
|
||||||
|
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", userAgentForURL(parsed))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||||
|
result.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
if isTransientExtensionHealthError(err) {
|
||||||
|
result.Status = "unknown"
|
||||||
|
} else {
|
||||||
|
result.Status = "offline"
|
||||||
|
}
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
result.HTTPStatus = resp.StatusCode
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
result.Status = "offline"
|
||||||
|
result.Message = resp.Status
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == http.MethodHead {
|
||||||
|
result.Status = "online"
|
||||||
|
result.Message = resp.Status
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "degraded"
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
|
||||||
|
result.Status = status
|
||||||
|
if message == "" {
|
||||||
|
result.Message = resp.Status
|
||||||
|
} else {
|
||||||
|
result.Message = message
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientExtensionHealthError(err error) bool {
|
||||||
|
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||||
|
if len(strings.TrimSpace(string(body))) == 0 {
|
||||||
|
return "online", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return "online", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceKey = strings.TrimSpace(serviceKey)
|
||||||
|
if serviceKey != "" {
|
||||||
|
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
|
||||||
|
return status, message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawStatus, _ := payload["status"].(string)
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
|
||||||
|
switch normalized {
|
||||||
|
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
|
||||||
|
return "online", rawStatus
|
||||||
|
case "degraded", "partial", "warning", "warn":
|
||||||
|
return "degraded", rawStatus
|
||||||
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
|
if isTransientHealthStatusMessage(string(body)) {
|
||||||
|
return "unknown", rawStatus
|
||||||
|
}
|
||||||
|
return "offline", rawStatus
|
||||||
|
default:
|
||||||
|
return "online", rawStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
|
||||||
|
rawServices, ok := payload["services"]
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
services, ok := rawServices.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
rawService, ok := services[serviceKey]
|
||||||
|
if !ok {
|
||||||
|
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
|
||||||
|
}
|
||||||
|
service, ok := rawService.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
|
||||||
|
}
|
||||||
|
|
||||||
|
label, _ := service["label"].(string)
|
||||||
|
detail, _ := service["detail"].(string)
|
||||||
|
errText, _ := service["error"].(string)
|
||||||
|
messageParts := []string{}
|
||||||
|
if strings.TrimSpace(label) != "" {
|
||||||
|
messageParts = append(messageParts, strings.TrimSpace(label))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(detail) != "" {
|
||||||
|
messageParts = append(messageParts, strings.TrimSpace(detail))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(errText) != "" {
|
||||||
|
messageParts = append(messageParts, strings.TrimSpace(errText))
|
||||||
|
}
|
||||||
|
|
||||||
|
rawStatus, hasStatus := service["status"]
|
||||||
|
okValue, hasOK := service["ok"].(bool)
|
||||||
|
joinedMessage := strings.Join(messageParts, ": ")
|
||||||
|
transient := isTransientHealthStatusMessage(detail) ||
|
||||||
|
isTransientHealthStatusMessage(errText) ||
|
||||||
|
isTransientHealthStatusMessage(label)
|
||||||
|
|
||||||
|
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||||
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
|
return "online", joinedMessage, true
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||||
|
return "degraded", joinedMessage, true
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||||
|
return "online", joinedMessage, true
|
||||||
|
}
|
||||||
|
if transient || isTransientHealthStatusCode(statusCode) {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
return "offline", joinedMessage, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExtensionHealthAuthRequired(detail) {
|
||||||
|
return "degraded", joinedMessage, true
|
||||||
|
}
|
||||||
|
if transient {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
if hasOK {
|
||||||
|
if okValue {
|
||||||
|
return "online", joinedMessage, true
|
||||||
|
}
|
||||||
|
return "offline", joinedMessage, true
|
||||||
|
}
|
||||||
|
if !hasStatus {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
|
||||||
|
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||||
|
switch statusString {
|
||||||
|
case "ok", "up", "online", "healthy", "operational":
|
||||||
|
return "online", joinedMessage, true
|
||||||
|
case "degraded", "partial", "warning", "warn":
|
||||||
|
return "degraded", joinedMessage, true
|
||||||
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
|
return "offline", joinedMessage, true
|
||||||
|
default:
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExtensionHealthAuthRequired(detail string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(detail)) {
|
||||||
|
case "auth_required", "authorization_required", "login_required", "unauthorized":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusMessage(text string) bool {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(t, "context deadline exceeded") ||
|
||||||
|
strings.Contains(t, "deadline exceeded") ||
|
||||||
|
strings.Contains(t, "timeout") ||
|
||||||
|
strings.Contains(t, "timed out") ||
|
||||||
|
strings.Contains(t, "temporarily unavailable") ||
|
||||||
|
strings.Contains(t, "try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusCode(code int) bool {
|
||||||
|
switch code {
|
||||||
|
case http.StatusRequestTimeout,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthNumber(value interface{}) (int, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v), true
|
||||||
|
case int:
|
||||||
|
return v, true
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Int64()
|
||||||
|
return int(n), err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||||
|
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
|
||||||
|
t.Fatalf("status/message = %q/%q", status, msg)
|
||||||
|
}
|
||||||
|
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
|
||||||
|
t.Fatalf("invalid JSON status = %q", status)
|
||||||
|
}
|
||||||
|
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
|
||||||
|
t.Fatalf("service status/message = %q/%q", status, msg)
|
||||||
|
}
|
||||||
|
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
|
||||||
|
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
|
||||||
|
}
|
||||||
|
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
|
||||||
|
t.Fatalf("health number = %d/%v", n, ok)
|
||||||
|
}
|
||||||
|
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||||
|
t.Fatal("expected auth required")
|
||||||
|
}
|
||||||
|
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||||
|
t.Fatal("expected timeout health errors to be transient")
|
||||||
|
}
|
||||||
|
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||||
|
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||||
|
t.Fatalf("nil health = %#v", result)
|
||||||
|
}
|
||||||
|
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
|
||||||
|
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
|
||||||
|
if invalidURL.Status != "offline" {
|
||||||
|
t.Fatalf("invalid URL = %#v", invalidURL)
|
||||||
|
}
|
||||||
|
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
|
||||||
|
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
|
||||||
|
t.Fatalf("insecure = %#v", insecure)
|
||||||
|
}
|
||||||
|
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
|
||||||
|
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
|
||||||
|
t.Fatalf("host = %#v", disallowedHost)
|
||||||
|
}
|
||||||
|
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
|
||||||
|
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
|
||||||
|
t.Fatalf("method = %#v", badMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "health-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
ServiceHealth: []ExtensionHealthCheck{
|
||||||
|
{ID: "required", URL: "http://status.example.com", Required: true},
|
||||||
|
{ID: "optional", URL: "http://status.example.com", Required: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
|
||||||
|
t.Fatalf("extension health = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
|
||||||
|
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
|
||||||
|
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
|
||||||
|
t.Fatalf("spotify cover = %q", got)
|
||||||
|
}
|
||||||
|
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
|
||||||
|
t.Fatalf("deezer cover = %q", got)
|
||||||
|
}
|
||||||
|
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
||||||
|
t.Fatalf("tidal cover = %q", got)
|
||||||
|
}
|
||||||
|
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
|
||||||
|
t.Fatalf("qobuz cover = %q", got)
|
||||||
|
}
|
||||||
|
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
|
||||||
|
t.Fatalf("expected empty cover error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
|
||||||
|
t.Fatal("unexpected Japanese detection")
|
||||||
|
}
|
||||||
|
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
|
||||||
|
t.Fatalf("romaji = %q", got)
|
||||||
|
}
|
||||||
|
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
|
||||||
|
t.Fatalf("query = %q", got)
|
||||||
|
}
|
||||||
|
if got := CleanToASCII("A, B. C!"); got != "A B C" {
|
||||||
|
t.Fatalf("ascii = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := PreWarmCache(`not-json`); err == nil {
|
||||||
|
t.Fatal("expected prewarm JSON error")
|
||||||
|
}
|
||||||
|
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
|
||||||
|
t.Fatalf("PreWarmCache: %v", err)
|
||||||
|
}
|
||||||
|
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
|
||||||
|
t.Fatalf("parallel result = %#v", result)
|
||||||
|
}
|
||||||
|
if ClearTrackCache(); GetCacheSize() != 0 {
|
||||||
|
t.Fatal("expected empty cache size")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})}}
|
||||||
|
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
|
||||||
|
}
|
||||||
|
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
|
||||||
|
t.Fatalf("spotify availability = %#v", availability)
|
||||||
|
}
|
||||||
|
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
|
||||||
|
}
|
||||||
|
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
|
||||||
|
t.Fatalf("deezer availability = %#v", deezerAvailability)
|
||||||
|
}
|
||||||
|
|
||||||
|
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
if _, err := errorClient.Search("bad", nil); err == nil {
|
||||||
|
t.Fatal("expected rate limit error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -43,18 +44,24 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isExtensionPackagePath(filePath string) bool {
|
||||||
|
lowerPath := strings.ToLower(filePath)
|
||||||
|
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||||
|
}
|
||||||
|
|
||||||
type loadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *extensionRuntime
|
runtime *extensionRuntime
|
||||||
initialized bool
|
indexProgram *goja.Program
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
@@ -117,7 +124,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type extensionManager struct {
|
type extensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
|
||||||
|
// teardown/reload), which are not safe to run concurrently. Acquired before
|
||||||
|
// m.mu; "*Locked" helpers assume it is held.
|
||||||
|
mutationMu sync.Mutex
|
||||||
extensions map[string]*loadedExtension
|
extensions map[string]*loadedExtension
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -155,13 +166,19 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
m.mutationMu.Lock()
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
defer m.mutationMu.Unlock()
|
||||||
|
return m.loadExtensionFromFileLocked(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||||
|
if !isExtensionPackagePath(filePath) {
|
||||||
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -186,16 +203,16 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
}
|
}
|
||||||
|
|
||||||
if manifestData == nil {
|
if manifestData == nil {
|
||||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasIndexJS {
|
if !hasIndexJS {
|
||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -211,11 +228,11 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
return m.UpgradeExtension(filePath)
|
return m.upgradeExtensionLocked(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +240,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := m.extensions[manifest.Name]; exists {
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
@@ -295,6 +312,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
func initializeVMLocked(ext *loadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
ext.VM = nil
|
ext.VM = nil
|
||||||
ext.runtime = nil
|
ext.runtime = nil
|
||||||
|
ext.indexProgram = nil
|
||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
@@ -304,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
ext.indexProgram = indexProgram
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
@@ -330,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err = vm.RunString(string(jsCode))
|
_, err = vm.RunProgram(indexProgram)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -342,23 +365,97 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
|
||||||
|
indexProgram := ext.indexProgram
|
||||||
|
if indexProgram == nil {
|
||||||
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: ext.ID,
|
||||||
|
manifest: ext.Manifest,
|
||||||
|
settings: make(map[string]interface{}),
|
||||||
|
cookieJar: nil,
|
||||||
|
dataDir: ext.DataDir,
|
||||||
|
vm: vm,
|
||||||
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
|
}
|
||||||
|
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
||||||
|
runtime.cookieJar = ext.runtime.cookieJar
|
||||||
|
} else {
|
||||||
|
jar, _ := newSimpleCookieJar()
|
||||||
|
runtime.cookieJar = jar
|
||||||
|
}
|
||||||
|
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||||
|
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
|
console := vm.NewObject()
|
||||||
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
|
args := make([]interface{}, len(call.Arguments))
|
||||||
|
for i, arg := range call.Arguments {
|
||||||
|
args[i] = arg.Export()
|
||||||
|
}
|
||||||
|
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
vm.Set("console", console)
|
||||||
|
|
||||||
|
var registeredExtension goja.Value
|
||||||
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
registeredExtension = call.Arguments[0]
|
||||||
|
vm.Set("extension", call.Arguments[0])
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm, runtime, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
return initializeVMLocked(ext)
|
return initializeVMLocked(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeExtensionWithSettingsLocked(
|
func initializeExtensionRuntimeWithSettings(
|
||||||
ext *loadedExtension,
|
vm *goja.Runtime,
|
||||||
|
extensionID string,
|
||||||
settings map[string]interface{},
|
settings map[string]interface{},
|
||||||
) error {
|
) error {
|
||||||
if ext.VM == nil {
|
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
settingsJSON, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to save settings")
|
return fmt.Errorf("failed to save settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -376,11 +473,9 @@ func initializeExtensionWithSettingsLocked(
|
|||||||
})()
|
})()
|
||||||
`, string(settingsJSON))
|
`, string(settingsJSON))
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
result, err := vm.RunString(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,14 +487,29 @@ func initializeExtensionWithSettingsLocked(
|
|||||||
if e, ok := resultMap["error"].(string); ok {
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
errMsg = e
|
errMsg = e
|
||||||
}
|
}
|
||||||
ext.Error = errMsg
|
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *loadedExtension,
|
||||||
|
settings map[string]interface{},
|
||||||
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ext.initialized = true
|
ext.initialized = true
|
||||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
return nil
|
return nil
|
||||||
@@ -407,45 +517,56 @@ func initializeExtensionWithSettingsLocked(
|
|||||||
|
|
||||||
func runCleanupLocked(ext *loadedExtension) error {
|
func runCleanupLocked(ext *loadedExtension) error {
|
||||||
if ext.VM != nil {
|
if ext.VM != nil {
|
||||||
script := `
|
if err := runCleanupOnVM(ext.VM); err != nil {
|
||||||
(function() {
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if ext.VM.Get("extension") != nil {
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runCleanupOnVM(vm *goja.Runtime) error {
|
||||||
|
if vm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := vm.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func teardownVMLocked(ext *loadedExtension) {
|
func teardownVMLocked(ext *loadedExtension) {
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
@@ -478,7 +599,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
|
|||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
@@ -497,7 +618,7 @@ func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, e
|
|||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("Extension not found")
|
return nil, fmt.Errorf("extension not found")
|
||||||
}
|
}
|
||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
@@ -519,7 +640,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -571,7 +692,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
loaded = append(loaded, ext.ID)
|
loaded = append(loaded, ext.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if isExtensionPackagePath(entry.Name()) {
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -597,12 +718,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
indexPath := filepath.Join(dirPath, "index.js")
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
return nil, fmt.Errorf("extension is missing index.js file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
@@ -644,6 +765,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||||
|
m.mutationMu.Lock()
|
||||||
|
defer m.mutationMu.Unlock()
|
||||||
|
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -664,13 +788,19 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
|||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
m.mutationMu.Lock()
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
defer m.mutationMu.Unlock()
|
||||||
|
return m.upgradeExtensionLocked(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||||
|
if !isExtensionPackagePath(filePath) {
|
||||||
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -695,16 +825,16 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if manifestData == nil {
|
if manifestData == nil {
|
||||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasIndexJS {
|
if !hasIndexJS {
|
||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -712,15 +842,15 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
if versionCompare < 0 {
|
if versionCompare < 0 {
|
||||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
}
|
}
|
||||||
if versionCompare == 0 {
|
if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
|
return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||||
@@ -813,14 +943,14 @@ type ExtensionUpgradeInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !isExtensionPackagePath(filePath) {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file")
|
return nil, fmt.Errorf("cannot open extension file")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -847,7 +977,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
return nil, fmt.Errorf("invalid manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -893,7 +1023,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
@@ -909,9 +1038,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
SkipLyrics bool `json:"skip_lyrics"`
|
SkipLyrics bool `json:"skip_lyrics"`
|
||||||
|
StopProviderFallback bool `json:"stop_provider_fallback"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
|
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,7 +1082,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Name: ext.Manifest.Name,
|
Name: ext.Manifest.Name,
|
||||||
DisplayName: ext.Manifest.DisplayName,
|
DisplayName: ext.Manifest.DisplayName,
|
||||||
Version: ext.Manifest.Version,
|
Version: ext.Manifest.Version,
|
||||||
Author: ext.Manifest.Author,
|
|
||||||
Description: ext.Manifest.Description,
|
Description: ext.Manifest.Description,
|
||||||
Homepage: ext.Manifest.Homepage,
|
Homepage: ext.Manifest.Homepage,
|
||||||
IconPath: iconPath,
|
IconPath: iconPath,
|
||||||
@@ -967,9 +1097,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
SkipLyrics: ext.Manifest.SkipLyrics,
|
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||||
|
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
|
ServiceHealth: ext.Manifest.ServiceHealth,
|
||||||
Capabilities: ext.Manifest.Capabilities,
|
Capabilities: ext.Manifest.Capabilities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -988,7 +1120,7 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
@@ -1006,7 +1138,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
|
|||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
@@ -1055,23 +1187,45 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
}
|
}
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||||
|
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||||
|
actionNameLiteral := strconv.Quote(actionName)
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
var actionName = %s;
|
||||||
try {
|
function runAction(fn) {
|
||||||
var result = extension.%s();
|
try {
|
||||||
if (result && typeof result.then === 'function') {
|
var result = fn();
|
||||||
// Handle promise - return pending status
|
if (result && typeof result.then === 'function') {
|
||||||
return { success: true, pending: true, message: 'Action started' };
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
|
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||||
|
var isArr = false;
|
||||||
|
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||||
|
isArr = Array.isArray(result);
|
||||||
|
}
|
||||||
|
if (!isArr) {
|
||||||
|
var out = { success: true };
|
||||||
|
for (var k in result) {
|
||||||
|
out[k] = result[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { success: true, result: result };
|
return { success: true, result: result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: e.toString() };
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
||||||
return { success: false, error: 'Action function not found: %s' };
|
return runAction(function() { return extension[actionName](); });
|
||||||
})()
|
}
|
||||||
`, actionName, actionName, actionName)
|
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
||||||
|
return runAction(function() { return session.completeGrant(); });
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: ' + actionName };
|
||||||
|
})()
|
||||||
|
`, actionNameLiteral)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionManagerPackageLifecycle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
extensionsDir := filepath.Join(dir, "extensions")
|
||||||
|
dataDir := filepath.Join(dir, "data")
|
||||||
|
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||||
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||||
|
t.Fatalf("SetDirectories: %v", err)
|
||||||
|
}
|
||||||
|
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
|
||||||
|
t.Fatalf("settings data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
js := `
|
||||||
|
var cleaned = false;
|
||||||
|
registerExtension({
|
||||||
|
initialize: function(settings) { this.settings = settings || {}; },
|
||||||
|
cleanup: function() { cleaned = true; },
|
||||||
|
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
|
||||||
|
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
|
||||||
|
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
|
||||||
|
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||||
|
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||||
|
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||||
|
});
|
||||||
|
`
|
||||||
|
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
|
||||||
|
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
|
||||||
|
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
|
||||||
|
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
|
||||||
|
|
||||||
|
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
|
||||||
|
t.Fatal("compareVersions mismatch")
|
||||||
|
}
|
||||||
|
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
|
||||||
|
t.Fatal("expected bad extension suffix error")
|
||||||
|
}
|
||||||
|
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
|
||||||
|
t.Fatal("expected invalid package error")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := manager.LoadExtensionFromFile(pkgV1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadExtensionFromFile: %v", err)
|
||||||
|
}
|
||||||
|
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
|
||||||
|
t.Fatalf("loaded extension = %#v", ext)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
|
||||||
|
t.Fatal("unsafe archive path should not be extracted")
|
||||||
|
}
|
||||||
|
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
|
||||||
|
t.Fatal("expected duplicate version error")
|
||||||
|
}
|
||||||
|
|
||||||
|
installedJSON, err := manager.GetInstalledExtensionsJSON()
|
||||||
|
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
|
||||||
|
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
|
||||||
|
}
|
||||||
|
var installed []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
|
||||||
|
t.Fatalf("decode installed = %#v/%v", installed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
|
||||||
|
t.Fatalf("settings Set: %v", err)
|
||||||
|
}
|
||||||
|
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
|
||||||
|
t.Fatalf("enable extension: %v", err)
|
||||||
|
}
|
||||||
|
if !ext.Enabled || ext.VM == nil || !ext.initialized {
|
||||||
|
t.Fatalf("enabled extension = %#v", ext)
|
||||||
|
}
|
||||||
|
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
|
||||||
|
t.Fatalf("InitializeExtension: %v", err)
|
||||||
|
}
|
||||||
|
action, err := manager.InvokeAction("manager-ext", "doAction")
|
||||||
|
if err != nil || action["success"] != true || action["message"] != "done" {
|
||||||
|
t.Fatalf("InvokeAction = %#v/%v", action, err)
|
||||||
|
}
|
||||||
|
if err := manager.CleanupExtension("manager-ext"); err != nil {
|
||||||
|
t.Fatalf("CleanupExtension: %v", err)
|
||||||
|
}
|
||||||
|
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
|
||||||
|
t.Fatalf("disable extension: %v", err)
|
||||||
|
}
|
||||||
|
if ext.VM != nil || ext.initialized {
|
||||||
|
t.Fatalf("expected VM teardown, got %#v", ext)
|
||||||
|
}
|
||||||
|
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
|
||||||
|
t.Fatal("expected disabled action error")
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
|
||||||
|
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||||
|
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
|
||||||
|
}
|
||||||
|
upgraded, err := manager.UpgradeExtension(pkgV2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpgradeExtension: %v", err)
|
||||||
|
}
|
||||||
|
if upgraded.Manifest.Version != "1.1.0" {
|
||||||
|
t.Fatalf("upgraded = %#v", upgraded.Manifest)
|
||||||
|
}
|
||||||
|
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
|
||||||
|
t.Fatal("expected downgrade error")
|
||||||
|
}
|
||||||
|
if err := manager.RemoveExtension("manager-ext"); err != nil {
|
||||||
|
t.Fatalf("RemoveExtension: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := manager.GetExtension("manager-ext"); err == nil {
|
||||||
|
t.Fatal("expected removed extension missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
dirExt := filepath.Join(extensionsDir, "dir-ext")
|
||||||
|
if err := os.MkdirAll(dirExt, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
|
||||||
|
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
|
||||||
|
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
|
||||||
|
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
|
||||||
|
}
|
||||||
|
manager.UnloadAllExtensions()
|
||||||
|
if len(manager.GetAllExtensions()) != 0 {
|
||||||
|
t.Fatal("expected all extensions unloaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,9 +26,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtensionPermissions struct {
|
type ExtensionPermissions struct {
|
||||||
Network []string `json:"network"`
|
Network []string `json:"network"`
|
||||||
Storage bool `json:"storage"`
|
Storage bool `json:"storage"`
|
||||||
File bool `json:"file"`
|
File bool `json:"file"`
|
||||||
|
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionSetting struct {
|
type ExtensionSetting struct {
|
||||||
@@ -101,27 +103,60 @@ type PostProcessingConfig struct {
|
|||||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExtensionHealthCheck struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
ServiceKey string `json:"serviceKey,omitempty"`
|
||||||
|
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||||
|
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignedSessionEndpoints struct {
|
||||||
|
Bootstrap string `json:"bootstrap,omitempty"`
|
||||||
|
Challenge string `json:"challenge,omitempty"`
|
||||||
|
Exchange string `json:"exchange,omitempty"`
|
||||||
|
Refresh string `json:"refresh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignedSessionConfig struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
AppVersion string `json:"appVersion,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||||
|
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||||
|
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||||
|
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||||
|
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Description string `json:"description"`
|
||||||
Description string `json:"description"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Types []ExtensionType `json:"type"`
|
||||||
Types []ExtensionType `json:"type"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||||
|
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||||
|
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
@@ -155,10 +190,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(m.Author) == "" {
|
|
||||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(m.Description) == "" {
|
if strings.TrimSpace(m.Description) == "" {
|
||||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||||
}
|
}
|
||||||
@@ -191,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select type requires options
|
|
||||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: fmt.Sprintf("settings[%d].options", i),
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
@@ -207,6 +237,48 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, check := range m.ServiceHealth {
|
||||||
|
if strings.TrimSpace(check.ID) == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("serviceHealth[%d].id", i),
|
||||||
|
Message: "health check id is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(check.URL) == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("serviceHealth[%d].url", i),
|
||||||
|
Message: "health check url is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||||
|
if method != "" && method != "GET" && method != "HEAD" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("serviceHealth[%d].method", i),
|
||||||
|
Message: "health check method must be GET or HEAD",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.SignedSession != nil {
|
||||||
|
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||||
|
if baseURL == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil || parsed.Hostname() == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||||
|
}
|
||||||
|
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +303,13 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
|
|||||||
return m.HasType(ExtensionTypeLyricsProvider)
|
return m.HasType(ExtensionTypeLyricsProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) StopsProviderFallback() bool {
|
||||||
|
if m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.StopProviderFallback || m.SkipBuiltInFallback
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extensionCallPerf struct {
|
||||||
|
extensionID string
|
||||||
|
operation string
|
||||||
|
startedAt time.Time
|
||||||
|
initMs float64
|
||||||
|
jsMs float64
|
||||||
|
parseMs float64
|
||||||
|
items int
|
||||||
|
payloadBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
|
||||||
|
if !GetLogBuffer().IsLoggingEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &extensionCallPerf{
|
||||||
|
extensionID: extensionID,
|
||||||
|
operation: operation,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extensionDurationMs(duration time.Duration) float64 {
|
||||||
|
return float64(duration.Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) recordInit(duration time.Duration) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.initMs += extensionDurationMs(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) recordJS(duration time.Duration) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.jsMs += extensionDurationMs(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) recordParse(duration time.Duration) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.parseMs += extensionDurationMs(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) recordPayload(value goja.Value) {
|
||||||
|
if p == nil || gojaValueIsEmpty(value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload, err := json.Marshal(value); err == nil {
|
||||||
|
p.payloadBytes = len(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.payloadBytes = payloadBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) setItems(items int) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionCallPerf) finish() {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogDebug(
|
||||||
|
"ExtensionPerf",
|
||||||
|
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
|
||||||
|
p.extensionID,
|
||||||
|
p.operation,
|
||||||
|
extensionDurationMs(time.Since(p.startedAt)),
|
||||||
|
p.initMs,
|
||||||
|
p.jsMs,
|
||||||
|
p.parseMs,
|
||||||
|
p.items,
|
||||||
|
p.payloadBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
|
||||||
|
if gojaValueIsEmpty(value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := value.ToObject(vm)
|
||||||
|
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
|
||||||
|
child := obj.Get(key)
|
||||||
|
if gojaValueIsEmpty(child) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||||
|
provider := newExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
|
search, err := provider.SearchTracks("query", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchTracks: %v", err)
|
||||||
|
}
|
||||||
|
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
|
||||||
|
t.Fatalf("search = %#v", search)
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := provider.GetTrack("track-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTrack: %v", err)
|
||||||
|
}
|
||||||
|
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
|
||||||
|
t.Fatalf("track = %#v", track)
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := provider.GetAlbum("album-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbum: %v", err)
|
||||||
|
}
|
||||||
|
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
|
||||||
|
t.Fatalf("album = %#v", album)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist, err := provider.GetPlaylist("playlist-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPlaylist: %v", err)
|
||||||
|
}
|
||||||
|
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
|
||||||
|
t.Fatalf("playlist = %#v", playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := provider.GetArtist("artist-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetArtist: %v", err)
|
||||||
|
}
|
||||||
|
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
|
||||||
|
t.Fatalf("artist = %#v", artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnrichTrack: %v", err)
|
||||||
|
}
|
||||||
|
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
|
||||||
|
t.Fatalf("enriched = %#v", enriched)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckAvailability: %v", err)
|
||||||
|
}
|
||||||
|
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
|
||||||
|
t.Fatalf("availability = %#v", availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDownloadURL: %v", err)
|
||||||
|
}
|
||||||
|
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
|
||||||
|
t.Fatalf("download URL = %#v", downloadURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := []int{}
|
||||||
|
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
|
||||||
|
progress = append(progress, percent)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download: %v", err)
|
||||||
|
}
|
||||||
|
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
|
||||||
|
t.Fatalf("download = %#v progress=%v", download, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLyrics: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
|
||||||
|
t.Fatalf("lyrics = %#v", lyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
urlResult, err := provider.HandleURL("https://example.test/track/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleURL: %v", err)
|
||||||
|
}
|
||||||
|
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
|
||||||
|
t.Fatalf("url result = %#v", urlResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
match, err := provider.MatchTrack(
|
||||||
|
map[string]interface{}{"name": "Song", "artists": "Artist"},
|
||||||
|
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchTrack: %v", err)
|
||||||
|
}
|
||||||
|
if !match.Matched || match.TrackID != "download-track" {
|
||||||
|
t.Fatalf("match = %#v", match)
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostProcess: %v", err)
|
||||||
|
}
|
||||||
|
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||||
|
t.Fatalf("post = %#v", post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||||
|
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
|
||||||
|
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
|
||||||
|
}}
|
||||||
|
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
|
||||||
|
t.Fatalf("capability list = %#v", values)
|
||||||
|
}
|
||||||
|
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
|
||||||
|
t.Fatal("extension replacement mismatch")
|
||||||
|
}
|
||||||
|
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
|
||||||
|
t.Fatal("trimKnownProviderPrefix mismatch")
|
||||||
|
}
|
||||||
|
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
|
||||||
|
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
|
||||||
|
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
|
||||||
|
t.Fatal("metadata dedup key mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||||
|
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
|
||||||
|
manager.extensions[downloadExt.ID] = downloadExt
|
||||||
|
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
|
||||||
|
t.Fatalf("download providers = %#v", providers)
|
||||||
|
}
|
||||||
|
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
|
||||||
|
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||||
|
t.Fatalf("provider priority = %#v", priority)
|
||||||
|
}
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
|
||||||
|
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
|
||||||
|
t.Fatalf("fallback ids = %#v", ids)
|
||||||
|
}
|
||||||
|
SetExtensionFallbackProviderIDs(nil)
|
||||||
|
if !isExtensionFallbackAllowed("z") {
|
||||||
|
t.Fatal("nil fallback list should allow all")
|
||||||
|
}
|
||||||
|
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
|
||||||
|
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||||
|
t.Fatalf("metadata priority = %#v", priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,39 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||||
original := GetMetadataProviderPriority()
|
original := GetMetadataProviderPriority()
|
||||||
defer SetMetadataProviderPriority(original)
|
defer SetMetadataProviderPriority(original)
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"tidal"})
|
SetMetadataProviderPriority([]string{"qobuz"})
|
||||||
got := GetMetadataProviderPriority()
|
got := GetMetadataProviderPriority()
|
||||||
want := []string{"tidal", "deezer", "qobuz"}
|
if len(got) != 0 {
|
||||||
if len(got) != len(want) {
|
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
|
||||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
|
||||||
original := GetExtensionFallbackProviderIDs()
|
original := GetExtensionFallbackProviderIDs()
|
||||||
defer SetExtensionFallbackProviderIDs(original)
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
|
||||||
|
|
||||||
got := GetExtensionFallbackProviderIDs()
|
got := GetExtensionFallbackProviderIDs()
|
||||||
want := []string{"ext-a", "ext-b"}
|
want := []string{"ext-a", "ext-b"}
|
||||||
@@ -50,9 +56,6 @@ func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
|||||||
if !isExtensionFallbackAllowed("custom-ext") {
|
if !isExtensionFallbackAllowed("custom-ext") {
|
||||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||||
}
|
}
|
||||||
if !isExtensionFallbackAllowed("qobuz") {
|
|
||||||
t.Fatal("expected built-in provider to remain allowed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||||
@@ -79,7 +82,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
|||||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||||
|
|
||||||
got := GetProviderPriority()
|
got := GetProviderPriority()
|
||||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
want := []string{"custom-ext"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
||||||
|
original := GetProviderPriority()
|
||||||
|
defer SetProviderPriority(original)
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
ext.ID = "deezer"
|
||||||
|
ext.Manifest.Name = "deezer"
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
previous, hadPrevious := manager.extensions[ext.ID]
|
||||||
|
manager.extensions[ext.ID] = ext
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
if hadPrevious {
|
||||||
|
manager.extensions[ext.ID] = previous
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, ext.ID)
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
SetProviderPriority([]string{"deezer", "custom-ext"})
|
||||||
|
|
||||||
|
got := GetProviderPriority()
|
||||||
|
want := []string{"deezer", "custom-ext"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
||||||
|
manager := getExtensionManager()
|
||||||
|
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
amazon.ID = "amazon"
|
||||||
|
amazon.Manifest.Name = "amazon"
|
||||||
|
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||||
|
ID: "main",
|
||||||
|
URL: "://bad",
|
||||||
|
Required: true,
|
||||||
|
}}
|
||||||
|
|
||||||
|
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
plain.ID = "plain"
|
||||||
|
plain.Manifest.Name = "plain"
|
||||||
|
|
||||||
|
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
deezer.ID = "deezer"
|
||||||
|
deezer.Manifest.Name = "deezer"
|
||||||
|
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||||
|
ID: "main",
|
||||||
|
URL: "https://example.test/health",
|
||||||
|
}}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
||||||
|
previousPlain, hadPlain := manager.extensions[plain.ID]
|
||||||
|
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
||||||
|
manager.extensions[amazon.ID] = amazon
|
||||||
|
manager.extensions[plain.ID] = plain
|
||||||
|
manager.extensions[deezer.ID] = deezer
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
if hadAmazon {
|
||||||
|
manager.extensions[amazon.ID] = previousAmazon
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, amazon.ID)
|
||||||
|
}
|
||||||
|
if hadPlain {
|
||||||
|
manager.extensions[plain.ID] = previousPlain
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, plain.ID)
|
||||||
|
}
|
||||||
|
if hadDeezer {
|
||||||
|
manager.extensions[deezer.ID] = previousDeezer
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, deezer.ID)
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
delete(extensionHealthCache, deezer.ID)
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
||||||
|
result: ExtensionHealthResult{
|
||||||
|
ExtensionID: deezer.ID,
|
||||||
|
Status: "online",
|
||||||
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
expiresAt: time.Now().Add(time.Minute),
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
|
got := prioritizeFallbackProvidersByHealth(
|
||||||
|
[]string{"amazon", "plain", "deezer"},
|
||||||
|
manager,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
want := []string{"deezer", "plain"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||||
if normalized == nil {
|
if normalized == nil {
|
||||||
@@ -123,6 +245,110 @@ func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
setPrivateIPCache("download.test", false, time.Minute)
|
||||||
|
|
||||||
|
originalTransport := sharedTransport
|
||||||
|
testTransport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
sharedTransport = testTransport
|
||||||
|
defer func() {
|
||||||
|
testTransport.CloseIdleConnections()
|
||||||
|
sharedTransport = originalTransport
|
||||||
|
}()
|
||||||
|
|
||||||
|
extDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
|
||||||
|
registerExtension({
|
||||||
|
download: function(trackID, quality, outputPath, onProgress) {
|
||||||
|
var result = file.download('https://download.test/' + trackID, outputPath, {
|
||||||
|
onProgress: function(written, total) {
|
||||||
|
if (onProgress) onProgress(50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!result || !result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: result && result.error ? result.error : 'download failed',
|
||||||
|
error_type: 'download_error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
return { success: true, file_path: result.path };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`), 0600); err != nil {
|
||||||
|
t.Fatalf("write extension index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
SetAllowedDownloadDirs([]string{outputDir})
|
||||||
|
defer SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "concurrent-download",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "concurrent-download",
|
||||||
|
Description: "Concurrent download test",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"download.test"},
|
||||||
|
File: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Enabled: true,
|
||||||
|
SourceDir: extDir,
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
provider := newExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
i := i
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
result, err := provider.Download(
|
||||||
|
fmt.Sprintf("track-%d", i),
|
||||||
|
"LOSSLESS",
|
||||||
|
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == nil || !result.Success {
|
||||||
|
errs <- fmt.Errorf("download failed: %#v", result)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
|
||||||
|
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||||
SetAllowedDownloadDirs(nil)
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
@@ -180,11 +406,111 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := buildOutputPath(DownloadRequest{
|
||||||
|
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: outputDir,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "{artist} - {title}",
|
||||||
|
})
|
||||||
|
|
||||||
|
base := filepath.Base(outputPath)
|
||||||
|
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||||
|
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||||
|
}
|
||||||
|
if strings.Contains(base, `"`) {
|
||||||
|
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputFD: 123,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "{artist} - {title}",
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
base := filepath.Base(resolved)
|
||||||
|
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||||
|
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldStopProviderFallback(t *testing.T) {
|
||||||
|
if shouldStopProviderFallback(nil) {
|
||||||
|
t.Fatal("nil availability should not stop fallback")
|
||||||
|
}
|
||||||
|
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
|
||||||
|
t.Fatal("availability without skip_fallback should not stop fallback")
|
||||||
|
}
|
||||||
|
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
|
||||||
|
t.Fatal("skip_fallback availability should stop fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
||||||
|
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||||
|
Reason: "direct SoundCloud track ID",
|
||||||
|
SkipFallback: true,
|
||||||
|
}, errors.New("ignored"))
|
||||||
|
|
||||||
|
if resp.Service != "soundcloud" {
|
||||||
|
t.Fatalf("service = %q", resp.Service)
|
||||||
|
}
|
||||||
|
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
|
||||||
|
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.ErrorType != "extension_error" {
|
||||||
|
t.Fatalf("error type = %q", resp.ErrorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
|
||||||
|
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||||
|
SkipFallback: true,
|
||||||
|
}, errors.New("lookup failed"))
|
||||||
|
|
||||||
|
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
|
||||||
|
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
|
||||||
|
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
|
||||||
|
t.Fatal("expected cancelled error to abort fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
|
||||||
|
const itemID = "cancelled-item"
|
||||||
|
initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
|
||||||
|
cancelDownload(itemID)
|
||||||
|
|
||||||
|
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
|
||||||
|
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||||
t.Fatalf("failed to create temp file: %v", err)
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
}
|
}
|
||||||
|
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
|
||||||
|
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp m4a file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if canEmbedGenreLabel("relative.flac") {
|
if canEmbedGenreLabel("relative.flac") {
|
||||||
t.Fatal("expected relative path to be rejected")
|
t.Fatal("expected relative path to be rejected")
|
||||||
@@ -195,55 +521,268 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
|||||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||||
t.Fatal("expected missing file to be rejected")
|
t.Fatal("expected missing file to be rejected")
|
||||||
}
|
}
|
||||||
|
if canEmbedGenreLabel(tempM4A) {
|
||||||
|
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
|
||||||
|
}
|
||||||
if !canEmbedGenreLabel(tempFile) {
|
if !canEmbedGenreLabel(tempFile) {
|
||||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||||
originalPriority := GetMetadataProviderPriority()
|
originalPriority := GetMetadataProviderPriority()
|
||||||
originalSearch := searchBuiltInMetadataTracksFunc
|
|
||||||
defer func() {
|
defer func() {
|
||||||
SetMetadataProviderPriority(originalPriority)
|
SetMetadataProviderPriority(originalPriority)
|
||||||
searchBuiltInMetadataTracksFunc = originalSearch
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
SetMetadataProviderPriority([]string{"qobuz"})
|
||||||
|
|
||||||
var calls []string
|
|
||||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
|
||||||
calls = append(calls, providerID)
|
|
||||||
switch providerID {
|
|
||||||
case "qobuz":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
|
||||||
}, nil
|
|
||||||
case "tidal":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
|
||||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
|
||||||
}, nil
|
|
||||||
case "deezer":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := getExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
}
|
}
|
||||||
if len(tracks) != 3 {
|
if len(tracks) != 0 {
|
||||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
||||||
}
|
}
|
||||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
}
|
||||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
|
||||||
}
|
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
vm := goja.New()
|
||||||
t.Fatalf("unexpected provider call order: %v", calls)
|
value, err := vm.RunString(`({
|
||||||
|
tracks: [{
|
||||||
|
id: "track-1",
|
||||||
|
name: "Song",
|
||||||
|
artists: "Artist",
|
||||||
|
album_name: "Album",
|
||||||
|
duration_ms: 123000,
|
||||||
|
cover_url: "https://img.test/cover.jpg",
|
||||||
|
external_links: { spotify: "spotify:track:1" },
|
||||||
|
audio_quality: "LOSSLESS"
|
||||||
|
}],
|
||||||
|
total: 9
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build object search result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := parseExtensionSearchResult(vm, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse object search result: %v", err)
|
||||||
|
}
|
||||||
|
if result.Total != 9 || len(result.Tracks) != 1 {
|
||||||
|
t.Fatalf("unexpected object result: %+v", result)
|
||||||
|
}
|
||||||
|
track := result.Tracks[0]
|
||||||
|
if track.ID != "track-1" ||
|
||||||
|
track.AlbumName != "Album" ||
|
||||||
|
track.DurationMS != 123000 ||
|
||||||
|
track.CoverURL != "https://img.test/cover.jpg" ||
|
||||||
|
track.ExternalLinks["spotify"] != "spotify:track:1" ||
|
||||||
|
track.AudioQuality != "LOSSLESS" {
|
||||||
|
t.Fatalf("unexpected parsed track: %+v", track)
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayValue, err := vm.RunString(`[
|
||||||
|
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
|
||||||
|
]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build array search result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse array search result: %v", err)
|
||||||
|
}
|
||||||
|
if arrayResult.Total != 1 ||
|
||||||
|
len(arrayResult.Tracks) != 1 ||
|
||||||
|
arrayResult.Tracks[0].AlbumName != "Other Album" ||
|
||||||
|
arrayResult.Tracks[0].DurationMS != 456000 {
|
||||||
|
t.Fatalf("unexpected array result: %+v", arrayResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
value, err := vm.RunString(`({
|
||||||
|
id: "album-1",
|
||||||
|
name: "Album",
|
||||||
|
artists: "Artist",
|
||||||
|
artistId: "artist-1",
|
||||||
|
coverUrl: "https://img.test/album.jpg",
|
||||||
|
releaseDate: "2024-02-03",
|
||||||
|
totalTracks: 2,
|
||||||
|
albumType: "album",
|
||||||
|
tracks: [
|
||||||
|
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
|
||||||
|
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
|
||||||
|
]
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build album value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := parseExtensionAlbumValue(vm, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse album: %v", err)
|
||||||
|
}
|
||||||
|
if album.ID != "album-1" ||
|
||||||
|
album.ArtistID != "artist-1" ||
|
||||||
|
album.CoverURL != "https://img.test/album.jpg" ||
|
||||||
|
album.TotalTracks != 2 ||
|
||||||
|
len(album.Tracks) != 2 ||
|
||||||
|
album.Tracks[0].DurationMS != 180000 ||
|
||||||
|
album.Tracks[1].DurationMS != 181000 {
|
||||||
|
t.Fatalf("unexpected album: %+v", album)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistValue, err := vm.RunString(`({
|
||||||
|
id: "artist-1",
|
||||||
|
name: "Artist",
|
||||||
|
imageUrl: "https://img.test/artist.jpg",
|
||||||
|
headerImage: "https://img.test/header.jpg",
|
||||||
|
listeners: 1234,
|
||||||
|
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
|
||||||
|
releases: [{id: "single-1", name: "Single"}],
|
||||||
|
topTracks: [{id: "top-1", name: "Top Song"}]
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build artist value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := parseExtensionArtistValue(vm, artistValue)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse artist: %v", err)
|
||||||
|
}
|
||||||
|
if artist.ID != "artist-1" ||
|
||||||
|
artist.ImageURL != "https://img.test/artist.jpg" ||
|
||||||
|
artist.HeaderImage != "https://img.test/header.jpg" ||
|
||||||
|
artist.Listeners != 1234 ||
|
||||||
|
len(artist.Albums) != 1 ||
|
||||||
|
len(artist.Albums[0].Tracks) != 1 ||
|
||||||
|
len(artist.Releases) != 1 ||
|
||||||
|
len(artist.TopTracks) != 1 {
|
||||||
|
t.Fatalf("unexpected artist: %+v", artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadValue, err := vm.RunString(`({
|
||||||
|
success: true,
|
||||||
|
filePath: "/tmp/song.flac",
|
||||||
|
alreadyExists: true,
|
||||||
|
bitDepth: 24,
|
||||||
|
sampleRate: 96000,
|
||||||
|
title: "Song",
|
||||||
|
albumArtist: "Album Artist",
|
||||||
|
lyricsLrc: "[00:00.00]Line",
|
||||||
|
decryptionKey: "001122",
|
||||||
|
decryption: {
|
||||||
|
strategy: "mp4_decryption_key",
|
||||||
|
key: "001122",
|
||||||
|
inputFormat: "m4a",
|
||||||
|
options: { map: "0:a" }
|
||||||
|
}
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build download value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
download := parseExtensionDownloadResultValue(vm, downloadValue)
|
||||||
|
if !download.Success ||
|
||||||
|
download.FilePath != "/tmp/song.flac" ||
|
||||||
|
!download.AlreadyExists ||
|
||||||
|
download.BitDepth != 24 ||
|
||||||
|
download.SampleRate != 96000 ||
|
||||||
|
download.AlbumArtist != "Album Artist" ||
|
||||||
|
download.LyricsLRC != "[00:00.00]Line" ||
|
||||||
|
download.Decryption == nil ||
|
||||||
|
download.Decryption.InputFormat != "m4a" ||
|
||||||
|
download.Decryption.Options["map"] != "0:a" {
|
||||||
|
t.Fatalf("unexpected download result: %+v", download)
|
||||||
|
}
|
||||||
|
|
||||||
|
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build availability value: %v", err)
|
||||||
|
}
|
||||||
|
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
|
||||||
|
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
|
||||||
|
t.Fatalf("unexpected availability: %+v", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExtensionURLHandleResult(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
value, err := vm.RunString(`({
|
||||||
|
type: "album",
|
||||||
|
name: "Shared Album",
|
||||||
|
coverUrl: "https://img.test/shared.jpg",
|
||||||
|
track: { id: "track-1", name: "Song" },
|
||||||
|
tracks: [{ id: "track-2", name: "Song 2" }],
|
||||||
|
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
|
||||||
|
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build URL handle value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := parseExtensionURLHandleValue(vm, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse URL handle: %v", err)
|
||||||
|
}
|
||||||
|
if result.Type != "album" ||
|
||||||
|
result.CoverURL != "https://img.test/shared.jpg" ||
|
||||||
|
result.Track == nil ||
|
||||||
|
result.Track.ID != "track-1" ||
|
||||||
|
len(result.Tracks) != 1 ||
|
||||||
|
result.Album == nil ||
|
||||||
|
len(result.Album.Tracks) != 1 ||
|
||||||
|
result.Artist == nil ||
|
||||||
|
len(result.Artist.TopTracks) != 1 {
|
||||||
|
t.Fatalf("unexpected URL handle result: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
|
||||||
|
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build match value: %v", err)
|
||||||
|
}
|
||||||
|
match := parseExtensionMatchTrackValue(vm, matchValue)
|
||||||
|
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
|
||||||
|
t.Fatalf("unexpected match result: %+v", match)
|
||||||
|
}
|
||||||
|
|
||||||
|
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build post-process value: %v", err)
|
||||||
|
}
|
||||||
|
post := parseExtensionPostProcessValue(vm, postValue)
|
||||||
|
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||||
|
t.Fatalf("unexpected post-process result: %+v", post)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsValue, err := vm.RunString(`({
|
||||||
|
syncType: "LINE_SYNCED",
|
||||||
|
instrumental: false,
|
||||||
|
plainLyrics: "Line",
|
||||||
|
provider: "Lyrics Provider",
|
||||||
|
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build lyrics value: %v", err)
|
||||||
|
}
|
||||||
|
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse lyrics: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics.SyncType != "LINE_SYNCED" ||
|
||||||
|
lyrics.PlainLyrics != "Line" ||
|
||||||
|
lyrics.Provider != "Lyrics Provider" ||
|
||||||
|
len(lyrics.Lines) != 1 ||
|
||||||
|
lyrics.Lines[0].StartTimeMs != 1000 ||
|
||||||
|
lyrics.Lines[0].EndTimeMs != 2000 {
|
||||||
|
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,38 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||||
|
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||||
|
// intended for users who route the app's traffic through a local proxy or
|
||||||
|
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||||
|
var allowPrivateNetworkAccess atomic.Bool
|
||||||
|
|
||||||
|
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||||
|
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||||
|
// layer via the platform bridge.
|
||||||
|
func SetAllowPrivateNetwork(allowed bool) {
|
||||||
|
allowPrivateNetworkAccess.Store(allowed)
|
||||||
|
if allowed {
|
||||||
|
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||||
|
} else {
|
||||||
|
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||||
|
func IsPrivateNetworkAllowed() bool {
|
||||||
|
return allowPrivateNetworkAccess.Load()
|
||||||
|
}
|
||||||
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -93,6 +118,9 @@ type extensionRuntime struct {
|
|||||||
activeDownloadMu sync.RWMutex
|
activeDownloadMu sync.RWMutex
|
||||||
activeDownloadItemID string
|
activeDownloadItemID string
|
||||||
|
|
||||||
|
activeRequestMu sync.RWMutex
|
||||||
|
activeRequestID string
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
storageLoaded bool
|
storageLoaded bool
|
||||||
@@ -136,12 +164,60 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
|||||||
storageFlushDelay: defaultStorageFlushDelay,
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
||||||
|
|
||||||
return runtime
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
|
||||||
|
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
|
||||||
|
if !ok {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := parseExtensionTimeoutSeconds(raw)
|
||||||
|
if seconds <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if seconds < 5 {
|
||||||
|
seconds = 5
|
||||||
|
}
|
||||||
|
if seconds > 300 {
|
||||||
|
seconds = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtensionTimeoutSeconds(raw interface{}) int {
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int32:
|
||||||
|
return int(v)
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float32:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
r.activeDownloadMu.Lock()
|
r.activeDownloadMu.Lock()
|
||||||
defer r.activeDownloadMu.Unlock()
|
defer r.activeDownloadMu.Unlock()
|
||||||
@@ -160,18 +236,59 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
|
|||||||
return r.activeDownloadItemID
|
return r.activeDownloadItemID
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
||||||
|
r.activeRequestMu.Lock()
|
||||||
|
defer r.activeRequestMu.Unlock()
|
||||||
|
r.activeRequestID = strings.TrimSpace(requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) clearActiveRequestID() {
|
||||||
|
r.activeRequestMu.Lock()
|
||||||
|
defer r.activeRequestMu.Unlock()
|
||||||
|
r.activeRequestID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) getActiveRequestID() string {
|
||||||
|
r.activeRequestMu.RLock()
|
||||||
|
defer r.activeRequestMu.RUnlock()
|
||||||
|
return r.activeRequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
itemID := r.getActiveDownloadItemID()
|
||||||
|
if itemID == "" {
|
||||||
|
requestID := r.getActiveRequestID()
|
||||||
|
if requestID == "" {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
return req.WithContext(initExtensionRequestCancel(requestID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.WithContext(initDownloadCancel(itemID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
// API calls can use response compression for faster metadata/search loads,
|
||||||
|
// while media downloads keep identity transfer semantics for progress/streaming.
|
||||||
|
transport := sharedTransport
|
||||||
|
if compressResponses {
|
||||||
|
transport = extensionAPITransport
|
||||||
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: transport,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
if req.URL.Scheme != "https" {
|
if req.URL.Scheme != "https" &&
|
||||||
|
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
|
||||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||||
}
|
}
|
||||||
@@ -210,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIP(host string) bool {
|
func isPrivateIP(host string) bool {
|
||||||
|
// Opt-in escape hatch: when the user has enabled private/local network
|
||||||
|
// access, treat every host as public so local proxies / custom DNS work.
|
||||||
|
if allowPrivateNetworkAccess.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
if hostLower == "" {
|
if hostLower == "" {
|
||||||
return false
|
return false
|
||||||
@@ -372,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
|
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||||
|
sessionObj := vm.NewObject()
|
||||||
|
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||||
|
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||||
|
sessionObj.Set("status", r.signedSessionStatus)
|
||||||
|
sessionObj.Set("clear", r.signedSessionClear)
|
||||||
|
vm.Set("session", sessionObj)
|
||||||
|
}
|
||||||
|
|
||||||
fileObj := vm.NewObject()
|
fileObj := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
@@ -411,8 +543,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||||
|
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
|
utilsObj.Set("appVersion", r.appVersion)
|
||||||
|
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||||
|
utilsObj.Set("sleep", r.sleep)
|
||||||
|
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||||
|
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
|
||||||
|
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
logObj := vm.NewObject()
|
logObj := vm.NewObject()
|
||||||
|
|||||||
@@ -458,9 +458,10 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", appUserAgent())
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
|
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
|
||||||
"golang.org/x/crypto/blowfish"
|
"golang.org/x/crypto/blowfish"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
|||||||
cloned := make([]byte, len(value))
|
cloned := make([]byte, len(value))
|
||||||
copy(cloned, value)
|
copy(cloned, value)
|
||||||
return cloned, nil
|
return cloned, nil
|
||||||
|
case goja.ArrayBuffer:
|
||||||
|
src := value.Bytes()
|
||||||
|
cloned := make([]byte, len(src))
|
||||||
|
copy(cloned, src)
|
||||||
|
return cloned, nil
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
decoded := make([]byte, len(value))
|
decoded := make([]byte, len(value))
|
||||||
for i, item := range value {
|
for i, item := range value {
|
||||||
@@ -278,7 +284,9 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if parsedOptions.Mode != "cbc" {
|
switch parsedOptions.Mode {
|
||||||
|
case "cbc", "ctr":
|
||||||
|
default:
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||||
@@ -302,37 +310,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(parsedOptions.IV) != block.BlockSize() {
|
if len(parsedOptions.IV) != block.BlockSize() {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
ivLabel := "iv"
|
||||||
"success": false,
|
if parsedOptions.Mode == "ctr" {
|
||||||
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
ivLabel = "iv (counter)"
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
data := inputData
|
|
||||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
|
||||||
data = applyPKCS7Padding(data, block.BlockSize())
|
|
||||||
}
|
|
||||||
if len(data)%block.BlockSize() != 0 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
output := make([]byte, len(data))
|
|
||||||
if decrypt {
|
|
||||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
|
||||||
if parsedOptions.Padding == "pkcs7" {
|
|
||||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var output []byte
|
||||||
|
if parsedOptions.Mode == "ctr" {
|
||||||
|
// CTR is a stream mode: encryption and decryption are identical,
|
||||||
|
// require no padding, and accept arbitrary input lengths.
|
||||||
|
output = make([]byte, len(inputData))
|
||||||
|
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
||||||
} else {
|
} else {
|
||||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
data := inputData
|
||||||
|
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||||
|
data = applyPKCS7Padding(data, block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(data)%block.BlockSize() != 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
output = make([]byte, len(data))
|
||||||
|
if decrypt {
|
||||||
|
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
if parsedOptions.Padding == "pkcs7" {
|
||||||
|
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||||
@@ -357,3 +377,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
|
|||||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
return r.transformBlockCipher(call, true)
|
return r.transformBlockCipher(call, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
||||||
|
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
||||||
|
// bridge crossings when an extension decrypts per-sample CENC media (each
|
||||||
|
// sample has its own IV/counter and cannot be merged into one stream).
|
||||||
|
//
|
||||||
|
// It is a generic primitive: any extension can use it for "one buffer, many
|
||||||
|
// CTR segments" workloads, not just Apple CENC.
|
||||||
|
//
|
||||||
|
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
||||||
|
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
||||||
|
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
||||||
|
// dominant cost under the goja interpreter.
|
||||||
|
//
|
||||||
|
// JS signature:
|
||||||
|
// utils.decryptCTRSegments(data, {
|
||||||
|
// algorithm: "aes", // optional, default "aes"
|
||||||
|
// key: "<hex>", keyEncoding: "hex",
|
||||||
|
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||||
|
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||||
|
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||||
|
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||||
|
// })
|
||||||
|
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||||
|
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||||
|
fail := func(msg string) goja.Value {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return fail("data and options are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
if options == nil {
|
||||||
|
return fail("options object is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
||||||
|
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
||||||
|
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
||||||
|
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
||||||
|
|
||||||
|
key, err := decodeRuntimeBytesString(
|
||||||
|
runtimeOptionString(options, "key", ""),
|
||||||
|
runtimeOptionString(options, "keyEncoding", "hex"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Sprintf("invalid key: %v", err))
|
||||||
|
}
|
||||||
|
if len(key) == 0 {
|
||||||
|
return fail("key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var block cipher.Block
|
||||||
|
switch algorithm {
|
||||||
|
case "aes":
|
||||||
|
block, err = aes.NewCipher(key)
|
||||||
|
case "blowfish":
|
||||||
|
block, err = blowfish.NewCipher(key)
|
||||||
|
default:
|
||||||
|
return fail("unsupported algorithm: " + algorithm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
blockSize := block.BlockSize()
|
||||||
|
|
||||||
|
// Decode the payload. For "bytes" input we operate on the raw []byte
|
||||||
|
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
||||||
|
var data []byte
|
||||||
|
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
||||||
|
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
||||||
|
if err != nil {
|
||||||
|
return fail("invalid byte payload: " + err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawSegments, ok := options["segments"]
|
||||||
|
if !ok || rawSegments == nil {
|
||||||
|
return fail("segments array is required")
|
||||||
|
}
|
||||||
|
segments, ok := rawSegments.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fail("segments must be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for i, rawSeg := range segments {
|
||||||
|
seg, ok := rawSeg.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
||||||
|
size := int(runtimeOptionInt64(seg, "size", -1))
|
||||||
|
if offset < 0 || size < 0 {
|
||||||
|
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if offset+size > len(data) {
|
||||||
|
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
||||||
|
}
|
||||||
|
if len(iv) != blockSize {
|
||||||
|
// Accept short IVs by left-aligning into a block-sized counter
|
||||||
|
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
||||||
|
if len(iv) > blockSize {
|
||||||
|
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
||||||
|
}
|
||||||
|
padded := make([]byte, blockSize)
|
||||||
|
copy(padded, iv)
|
||||||
|
iv = padded
|
||||||
|
}
|
||||||
|
|
||||||
|
segData := data[offset : offset+size]
|
||||||
|
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||||
|
// base64). Otherwise fall back to an encoded string.
|
||||||
|
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": r.vm.NewArrayBuffer(data),
|
||||||
|
"segments_processed": processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"segments_processed": processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
|||||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
|
||||||
|
// Key: 2b7e151628aed2a6abf7158809cf4f3c
|
||||||
|
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
|
||||||
|
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
|
||||||
|
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "2b7e151628aed2a6abf7158809cf4f3c",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
// CTR is symmetric: decrypt is the same transform as encrypt.
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Enc string `json:"enc"`
|
||||||
|
Dec string `json:"dec"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
|
||||||
|
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
|
||||||
|
}
|
||||||
|
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
|
||||||
|
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
|
||||||
|
// must round-trip without any padding.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "utf8",
|
||||||
|
outputEncoding: "base64"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: options.key,
|
||||||
|
keyEncoding: options.keyEncoding,
|
||||||
|
iv: options.iv,
|
||||||
|
ivEncoding: options.ivEncoding,
|
||||||
|
inputEncoding: "base64",
|
||||||
|
outputEncoding: "utf8"
|
||||||
|
});
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return dec.data;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr stream length failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != "stream ctr of odd length" {
|
||||||
|
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var res = utils.encryptBlockCipher("00112233", {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0001",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex"
|
||||||
|
});
|
||||||
|
return JSON.stringify({success: res.success, error: res.error || ""});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr bad iv eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Success {
|
||||||
|
t.Fatal("expected failure for undersized CTR iv")
|
||||||
|
}
|
||||||
|
if decoded.Error == "" {
|
||||||
|
t.Fatal("expected error message for undersized CTR iv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
|
||||||
|
// style), then verify the batch primitive decrypts all of them in one call,
|
||||||
|
// matching what per-segment decryptBlockCipher would produce.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||||
|
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
|
||||||
|
|
||||||
|
// segment plaintexts (hex) and 8-byte IVs (hex)
|
||||||
|
var segs = [
|
||||||
|
{ pt: "11111111111111111111", iv: "0000000000000001" },
|
||||||
|
{ pt: "2222222222", iv: "0000000000000002" },
|
||||||
|
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Encrypt each segment individually using single-shot CTR with a
|
||||||
|
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
|
||||||
|
function ivToB64(ivHex){
|
||||||
|
// pad 8-byte hex iv to 16 bytes then base64
|
||||||
|
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
|
||||||
|
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cipherHex = "";
|
||||||
|
var offsets = [];
|
||||||
|
var off = 0;
|
||||||
|
var ivB64s = [];
|
||||||
|
for (var i=0;i<segs.length;i++){
|
||||||
|
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
|
||||||
|
var enc = utils.encryptBlockCipher(segs[i].pt, {
|
||||||
|
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||||
|
iv: ivFullHex, ivEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex"
|
||||||
|
});
|
||||||
|
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
|
||||||
|
cipherHex += enc.data;
|
||||||
|
var sz = segs[i].pt.length/2;
|
||||||
|
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
|
||||||
|
off += sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now decrypt the whole concatenated buffer in ONE batch call.
|
||||||
|
var segments = offsets.map(function(o){
|
||||||
|
return { offset:o.offset, size:o.size, iv:o.ivHex };
|
||||||
|
});
|
||||||
|
var batch = utils.decryptCTRSegments(cipherHex, {
|
||||||
|
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||||
|
segments: segments, ivEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex"
|
||||||
|
});
|
||||||
|
if(!batch.success) throw new Error("batch: "+batch.error);
|
||||||
|
|
||||||
|
var expected = "";
|
||||||
|
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
out: batch.data,
|
||||||
|
expected: expected,
|
||||||
|
processed: batch.segments_processed
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch CTR eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Out string `json:"out"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
Processed int `json:"processed"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Out != decoded.Expected {
|
||||||
|
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||||
|
}
|
||||||
|
if decoded.Processed != 3 {
|
||||||
|
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var res = utils.decryptCTRSegments("00112233", {
|
||||||
|
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex",
|
||||||
|
ivEncoding:"hex",
|
||||||
|
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
|
||||||
|
});
|
||||||
|
return JSON.stringify({ success: res.success, error: res.error || "" });
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("oob eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Success {
|
||||||
|
t.Fatal("expected out-of-bounds segment to fail")
|
||||||
|
}
|
||||||
|
if decoded.Error == "" {
|
||||||
|
t.Fatal("expected error message for out-of-bounds segment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
|
||||||
|
// and confirm round-trip correctness against single-shot CTR.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||||
|
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
|
||||||
|
|
||||||
|
// Plaintext as a Uint8Array of 20 bytes.
|
||||||
|
var pt = new Uint8Array(20);
|
||||||
|
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
|
||||||
|
|
||||||
|
// Encrypt single-shot to get ciphertext (hex output for clarity).
|
||||||
|
var ptHex = "";
|
||||||
|
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
|
||||||
|
var enc = utils.encryptBlockCipher(ptHex, {
|
||||||
|
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||||
|
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
|
||||||
|
});
|
||||||
|
if (!enc.success) throw new Error("enc: " + enc.error);
|
||||||
|
|
||||||
|
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
|
||||||
|
var cipherBytes = utils.base64Decode ? null : null;
|
||||||
|
// Build ArrayBuffer from base64 via Uint8Array manually:
|
||||||
|
var b64 = enc.data;
|
||||||
|
var bin = (typeof atob === "function") ? null : null;
|
||||||
|
|
||||||
|
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
|
||||||
|
// so just pass the base64 ciphertext through decryptCTRSegments using
|
||||||
|
// base64 input but bytes output, then re-run with bytes input.
|
||||||
|
var step1 = utils.decryptCTRSegments(b64, {
|
||||||
|
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||||
|
segments: [ { offset:0, size:20, iv: ivFullHex } ],
|
||||||
|
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
|
||||||
|
});
|
||||||
|
if (!step1.success) throw new Error("step1: " + step1.error);
|
||||||
|
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
|
||||||
|
|
||||||
|
var outArr = new Uint8Array(step1.data);
|
||||||
|
var outHex = "";
|
||||||
|
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
|
||||||
|
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("raw-bytes eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Out string `json:"out"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
Len int `json:"len"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Out != decoded.Expected {
|
||||||
|
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||||
|
}
|
||||||
|
if decoded.Len != 20 {
|
||||||
|
t.Fatalf("output length = %d, want 20", decoded.Len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
"sample_rate": quality.SampleRate,
|
"sample_rate": quality.SampleRate,
|
||||||
"total_samples": quality.TotalSamples,
|
"total_samples": quality.TotalSamples,
|
||||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||||
|
"codec": quality.Codec,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -134,6 +135,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
var onProgress goja.Callable
|
var onProgress goja.Callable
|
||||||
var headers map[string]string
|
var headers map[string]string
|
||||||
|
var chunkedDownload bool
|
||||||
|
trackItemBytes := true
|
||||||
|
var chunkSize int64
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
optionsObj := call.Arguments[2].Export()
|
optionsObj := call.Arguments[2].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
@@ -148,9 +152,39 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
onProgress = callable
|
onProgress = callable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if trackBytes, ok := opts["trackItemBytes"]; ok {
|
||||||
|
if v, ok := trackBytes.(bool); ok {
|
||||||
|
trackItemBytes = v
|
||||||
|
}
|
||||||
|
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
|
||||||
|
if v, ok := trackBytes.(bool); ok {
|
||||||
|
trackItemBytes = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunked, ok := opts["chunked"]; ok {
|
||||||
|
switch v := chunked.(type) {
|
||||||
|
case bool:
|
||||||
|
chunkedDownload = v
|
||||||
|
case int64:
|
||||||
|
if v > 0 {
|
||||||
|
chunkedDownload = true
|
||||||
|
chunkSize = v
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if v > 0 {
|
||||||
|
chunkedDownload = true
|
||||||
|
chunkSize = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default chunk size: 1MB (YouTube CDN max without poToken)
|
||||||
|
if chunkedDownload && chunkSize <= 0 {
|
||||||
|
chunkSize = 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(fullPath)
|
dir := filepath.Dir(fullPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -159,6 +193,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := r.downloadClient
|
||||||
|
if client == nil {
|
||||||
|
client = r.httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := appUserAgent()
|
||||||
|
if h, ok := headers["User-Agent"]; ok && h != "" {
|
||||||
|
ua = h
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunkedDownload {
|
||||||
|
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", urlStr, nil)
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -166,17 +214,13 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", appUserAgent())
|
||||||
}
|
|
||||||
|
|
||||||
client := r.downloadClient
|
|
||||||
if client == nil {
|
|
||||||
client = r.httpClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -188,7 +232,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||||
@@ -204,14 +248,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
|
||||||
activeItemID := r.getActiveDownloadItemID()
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
if activeItemID != "" && contentLength > 0 {
|
if activeItemID != "" {
|
||||||
|
SetItemDownloading(activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLength := resp.ContentLength
|
||||||
|
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||||
|
if shouldTrackItemBytes && contentLength > 0 {
|
||||||
SetItemBytesTotal(activeItemID, contentLength)
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
if activeItemID != "" {
|
if shouldTrackItemBytes {
|
||||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldTrackItemBytes {
|
||||||
|
if contentLength > 0 {
|
||||||
|
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
|
||||||
|
} else if written > 0 {
|
||||||
|
SetItemBytesReceived(activeItemID, written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -271,6 +328,236 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||||
|
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||||
|
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||||
|
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
||||||
|
// First, get the total content length with a small probe request
|
||||||
|
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: probe request error: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
probeReq = r.bindDownloadCancelContext(probeReq)
|
||||||
|
probeReq.Header.Set("User-Agent", ua)
|
||||||
|
for k, v := range headers {
|
||||||
|
if k != "Range" { // Don't copy any existing Range header
|
||||||
|
probeReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probeReq.Header.Set("Range", "bytes=0-1")
|
||||||
|
|
||||||
|
probeResp, err := client.Do(probeReq)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: probe error: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, probeResp.Body)
|
||||||
|
probeResp.Body.Close()
|
||||||
|
|
||||||
|
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
|
||||||
|
var totalSize int64
|
||||||
|
contentRange := probeResp.Header.Get("Content-Range")
|
||||||
|
if contentRange != "" {
|
||||||
|
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||||
|
sizeStr := contentRange[idx+1:]
|
||||||
|
if sizeStr != "*" {
|
||||||
|
fmt.Sscanf(sizeStr, "%d", &totalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalSize <= 0 {
|
||||||
|
// Fallback: try Content-Length from a HEAD-like approach
|
||||||
|
// If we can't determine size, download with unknown size
|
||||||
|
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
|
||||||
|
} else {
|
||||||
|
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
|
if activeItemID != "" {
|
||||||
|
SetItemDownloading(activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||||
|
if shouldTrackItemBytes && totalSize > 0 {
|
||||||
|
SetItemBytesTotal(activeItemID, totalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
|
if shouldTrackItemBytes {
|
||||||
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalWritten int64
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
maxRetries := 3
|
||||||
|
|
||||||
|
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
|
||||||
|
end := offset + chunkSize - 1
|
||||||
|
if totalSize > 0 && end >= totalSize {
|
||||||
|
end = totalSize - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkResp *http.Response
|
||||||
|
var chunkErr error
|
||||||
|
|
||||||
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
|
chunkReq, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
chunkReq = r.bindDownloadCancelContext(chunkReq)
|
||||||
|
chunkReq.Header.Set("User-Agent", ua)
|
||||||
|
for k, v := range headers {
|
||||||
|
if k != "Range" {
|
||||||
|
chunkReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
|
||||||
|
|
||||||
|
chunkResp, chunkErr = client.Do(chunkReq)
|
||||||
|
if chunkErr != nil {
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
time.Sleep(time.Duration(retry+1) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
|
||||||
|
break // Success
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(io.Discard, chunkResp.Body)
|
||||||
|
chunkResp.Body.Close()
|
||||||
|
|
||||||
|
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkWritten := int64(0)
|
||||||
|
for {
|
||||||
|
nr, er := chunkResp.Body.Read(buf)
|
||||||
|
if nr > 0 {
|
||||||
|
nw, ew := progressWriter.Write(buf[0:nr])
|
||||||
|
if nw < 0 || nr < nw {
|
||||||
|
nw = 0
|
||||||
|
if ew == nil {
|
||||||
|
ew = fmt.Errorf("invalid write result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunkWritten += int64(nw)
|
||||||
|
totalWritten += int64(nw)
|
||||||
|
if ew != nil {
|
||||||
|
chunkResp.Body.Close()
|
||||||
|
if ew == ErrDownloadCancelled {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "download cancelled",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if nr != nw {
|
||||||
|
chunkResp.Body.Close()
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "short write",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if onProgress != nil && totalSize > 0 {
|
||||||
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if er != nil {
|
||||||
|
if er != io.EOF {
|
||||||
|
chunkResp.Body.Close()
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunkResp.Body.Close()
|
||||||
|
|
||||||
|
offset += chunkWritten
|
||||||
|
|
||||||
|
// If server returned 200 (full content) instead of 206, we're done
|
||||||
|
if chunkResp.StatusCode == 200 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got less data than expected and we know total size, check if done
|
||||||
|
if totalSize > 0 && offset >= totalSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown size: if we got less than chunk size, assume done
|
||||||
|
if totalSize <= 0 && chunkWritten < chunkSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldTrackItemBytes {
|
||||||
|
if totalSize > 0 {
|
||||||
|
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
|
||||||
|
} else if totalWritten > 0 {
|
||||||
|
SetItemBytesReceived(activeItemID, totalWritten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"size": totalWritten,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -373,7 +660,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
"error": "offset must be >= 0",
|
"error": "offset must be >= 0",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(fullPath)
|
file, err := os.Open(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -426,6 +712,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
|
||||||
|
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
|
||||||
|
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
|
||||||
|
// large payloads under the goja interpreter.
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": r.vm.NewArrayBuffer(data),
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"offset": offset,
|
||||||
|
"size": size,
|
||||||
|
"eof": offset+int64(len(data)) >= size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -443,7 +743,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
"eof": offset+int64(len(data)) >= size,
|
"eof": offset+int64(len(data)) >= size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ type HTTPResponse struct {
|
|||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxExtensionHTTPResponseBytes = 16 << 20
|
||||||
|
|
||||||
|
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
|
body, err := io.ReadAll(
|
||||||
|
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) > maxExtensionHTTPResponseBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"response body exceeds %d byte limit; use file.download for large media",
|
||||||
|
maxExtensionHTTPResponseBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
|||||||
if parsed.Scheme == "" {
|
if parsed.Scheme == "" {
|
||||||
return fmt.Errorf("invalid URL: scheme is required")
|
return fmt.Errorf("invalid URL: scheme is required")
|
||||||
}
|
}
|
||||||
if parsed.Scheme != "https" {
|
if parsed.Scheme != "https" &&
|
||||||
|
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
|
||||||
return fmt.Errorf("network access denied: only https is allowed")
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
}
|
}
|
||||||
if parsed.User != nil {
|
if parsed.User != nil {
|
||||||
@@ -81,6 +100,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -98,7 +118,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := readExtensionHTTPResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -175,6 +195,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -195,7 +216,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := readExtensionHTTPResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -284,6 +305,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -304,7 +326,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := readExtensionHTTPResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -410,6 +432,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -429,7 +452,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := readExtensionHTTPResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
@@ -69,12 +69,13 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", appUserAgent())
|
||||||
}
|
}
|
||||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -340,16 +340,6 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|
||||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.credentialsMu.RLock()
|
|
||||||
defer r.credentialsMu.RUnlock()
|
|
||||||
return cloneInterfaceMap(r.credentialsCache), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: "auth-ext",
|
||||||
|
manifest: &ExtensionManifest{
|
||||||
|
Name: "auth-ext",
|
||||||
|
Description: "Auth extension",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: map[string]interface{}{},
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.URL.Host {
|
||||||
|
case "token.example.com":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
case "api.example.com":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: http.Header{"X-Test": []string{"yes"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||||
|
}
|
||||||
|
})},
|
||||||
|
vm: vm,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
|
||||||
|
t.Fatal("expected embedded credential error")
|
||||||
|
}
|
||||||
|
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
|
||||||
|
t.Fatal("expected non-https auth URL error")
|
||||||
|
}
|
||||||
|
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
|
||||||
|
t.Fatalf("summary = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("https://auth.example.com/login"),
|
||||||
|
vm.ToValue("app://callback"),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if openResult["success"] != true {
|
||||||
|
t.Fatalf("authOpenUrl = %#v", openResult)
|
||||||
|
}
|
||||||
|
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
|
||||||
|
t.Fatalf("pending auth = %#v", pending)
|
||||||
|
}
|
||||||
|
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
|
||||||
|
t.Fatalf("expected undefined code, got %v", code)
|
||||||
|
}
|
||||||
|
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
|
||||||
|
t.Fatal("authSetCode returned false")
|
||||||
|
}
|
||||||
|
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
|
||||||
|
t.Fatalf("code = %q", code)
|
||||||
|
}
|
||||||
|
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("expected authenticated runtime")
|
||||||
|
}
|
||||||
|
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
|
||||||
|
if tokens["access_token"] != "access" {
|
||||||
|
t.Fatalf("tokens = %#v", tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
|
||||||
|
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
|
||||||
|
t.Fatalf("pkce = %#v", pkce)
|
||||||
|
}
|
||||||
|
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
|
||||||
|
t.Fatalf("current pkce = %#v", current)
|
||||||
|
}
|
||||||
|
oauthConfig := map[string]interface{}{
|
||||||
|
"authUrl": "https://auth.example.com/oauth",
|
||||||
|
"clientId": "client",
|
||||||
|
"redirectUri": "app://callback",
|
||||||
|
"scope": "read",
|
||||||
|
"extraParams": map[string]interface{}{"prompt": "login"},
|
||||||
|
}
|
||||||
|
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
|
||||||
|
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
|
||||||
|
t.Fatalf("oauth = %#v", oauth)
|
||||||
|
}
|
||||||
|
tokenConfig := map[string]interface{}{
|
||||||
|
"tokenUrl": "https://token.example.com/token",
|
||||||
|
"clientId": "client",
|
||||||
|
"redirectUri": "app://callback",
|
||||||
|
"code": "abc",
|
||||||
|
}
|
||||||
|
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
|
||||||
|
if token["success"] != true || token["access_token"] != "access" {
|
||||||
|
t.Fatalf("token = %#v", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.registerTextEncoderDecoder(vm)
|
||||||
|
runtime.registerURLClass(vm)
|
||||||
|
runtime.registerJSONGlobal(vm)
|
||||||
|
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return runtime.fetchPolyfill(call)
|
||||||
|
})
|
||||||
|
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return runtime.atobPolyfill(call)
|
||||||
|
})
|
||||||
|
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return runtime.btoaPolyfill(call)
|
||||||
|
})
|
||||||
|
|
||||||
|
value, err := vm.RunString(`
|
||||||
|
var encoded = btoa("hello");
|
||||||
|
var decoded = atob(encoded);
|
||||||
|
var te = new TextEncoder();
|
||||||
|
var bytes = te.encode("hi");
|
||||||
|
var into = te.encodeInto("hi", []);
|
||||||
|
var td = new TextDecoder();
|
||||||
|
var text = td.decode(bytes);
|
||||||
|
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
|
||||||
|
var params = new URLSearchParams("?x=1");
|
||||||
|
params.append("x", "2");
|
||||||
|
params.set("y", "3");
|
||||||
|
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
|
||||||
|
JSON.stringify({
|
||||||
|
encoded: encoded,
|
||||||
|
decoded: decoded,
|
||||||
|
text: text,
|
||||||
|
read: into.read,
|
||||||
|
host: url.hostname,
|
||||||
|
first: url.searchParams.get("a"),
|
||||||
|
all: url.searchParams.getAll("a").length,
|
||||||
|
params: params.toString(),
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
jsonOk: response.json().ok,
|
||||||
|
bufferLen: response.arrayBuffer().length
|
||||||
|
});
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("polyfill script: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
|
||||||
|
t.Fatalf("decode polyfill result: %v", err)
|
||||||
|
}
|
||||||
|
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
|
||||||
|
t.Fatalf("polyfill result = %#v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
|
||||||
|
if blocked.Get("ok").ToBoolean() {
|
||||||
|
t.Fatal("expected blocked fetch")
|
||||||
|
}
|
||||||
|
runtime.authClear(goja.FunctionCall{})
|
||||||
|
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("expected auth cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := &extensionStore{
|
||||||
|
registryURL: "https://registry.example.com/registry.json",
|
||||||
|
cacheDir: dir,
|
||||||
|
cacheTTL: time.Hour,
|
||||||
|
cache: &storeRegistry{
|
||||||
|
Version: 1,
|
||||||
|
UpdatedAt: "2026-05-04",
|
||||||
|
Extensions: []storeExtension{
|
||||||
|
{
|
||||||
|
ID: "coverage-ext",
|
||||||
|
Name: "coverage-ext",
|
||||||
|
DisplayNameAlt: "Coverage Extension",
|
||||||
|
Version: "2.0.0",
|
||||||
|
Description: "Metadata and lyrics provider",
|
||||||
|
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
|
||||||
|
IconURLAlt: "https://registry.example.com/icon.png",
|
||||||
|
Category: CategoryMetadata,
|
||||||
|
Tags: []string{"metadata", "lyrics"},
|
||||||
|
Downloads: 10,
|
||||||
|
UpdatedAt: "2026-05-04",
|
||||||
|
MinAppVersionAlt: "4.5.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "utility-ext",
|
||||||
|
Name: "utility-ext",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Description: "Utility",
|
||||||
|
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
|
||||||
|
Category: CategoryUtility,
|
||||||
|
UpdatedAt: "2026-05-04",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cacheTime: time.Now(),
|
||||||
|
}
|
||||||
|
store.saveDiskCache()
|
||||||
|
loadedStore := &extensionStore{cacheDir: dir}
|
||||||
|
loadedStore.loadDiskCache()
|
||||||
|
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
|
||||||
|
t.Fatalf("loaded cache = %#v", loadedStore.cache)
|
||||||
|
}
|
||||||
|
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
|
||||||
|
t.Fatalf("registry URL = %q", got)
|
||||||
|
}
|
||||||
|
store.setRegistryURL("https://registry.example.com/new.json")
|
||||||
|
if store.cache != nil {
|
||||||
|
t.Fatal("expected cache reset after registry URL change")
|
||||||
|
}
|
||||||
|
store.cache = loadedStore.cache
|
||||||
|
store.cacheTime = time.Now()
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
if manager.extensions == nil {
|
||||||
|
manager.extensions = map[string]*loadedExtension{}
|
||||||
|
}
|
||||||
|
manager.extensions["coverage-ext"] = &loadedExtension{
|
||||||
|
ID: "coverage-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "coverage-ext",
|
||||||
|
DisplayName: "Coverage Extension",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Description: "Installed",
|
||||||
|
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
||||||
|
},
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
delete(manager.extensions, "coverage-ext")
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
extensions, err := store.getExtensionsWithStatus(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getExtensionsWithStatus: %v", err)
|
||||||
|
}
|
||||||
|
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
|
||||||
|
t.Fatalf("extensions = %#v", extensions)
|
||||||
|
}
|
||||||
|
found, err := store.searchExtensions("lyrics", CategoryMetadata)
|
||||||
|
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
|
||||||
|
t.Fatalf("search = %#v/%v", found, err)
|
||||||
|
}
|
||||||
|
all, err := store.searchExtensions("", "")
|
||||||
|
if err != nil || len(all) != 2 {
|
||||||
|
t.Fatalf("all search = %#v/%v", all, err)
|
||||||
|
}
|
||||||
|
if cats := store.getCategories(); len(cats) != 5 {
|
||||||
|
t.Fatalf("categories = %#v", cats)
|
||||||
|
}
|
||||||
|
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
|
||||||
|
t.Fatal("string helper mismatch")
|
||||||
|
}
|
||||||
|
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
|
||||||
|
t.Fatal("expected HTTPS validation error")
|
||||||
|
}
|
||||||
|
if _, err := resolveRegistryURL(""); err == nil {
|
||||||
|
t.Fatal("expected empty registry URL error")
|
||||||
|
}
|
||||||
|
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
|
||||||
|
t.Fatalf("resolved registry = %q/%v", resolved, err)
|
||||||
|
}
|
||||||
|
store.clearCache()
|
||||||
|
if store.cache != nil {
|
||||||
|
t.Fatal("expected cleared store cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||||
|
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
|
||||||
|
t.Fatalf("SetDataDir: %v", err)
|
||||||
|
}
|
||||||
|
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
|
||||||
|
t.Fatalf("settings Set: %v", err)
|
||||||
|
}
|
||||||
|
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
|
||||||
|
t.Fatalf("settings Get = %#v/%v", value, err)
|
||||||
|
}
|
||||||
|
if _, err := settingsStore.Get("ext", "missing"); err == nil {
|
||||||
|
t.Fatal("expected missing setting error")
|
||||||
|
}
|
||||||
|
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
|
||||||
|
t.Fatalf("settings SetAll: %v", err)
|
||||||
|
}
|
||||||
|
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
|
||||||
|
t.Fatalf("settings all = %#v", all)
|
||||||
|
}
|
||||||
|
if err := settingsStore.Remove("ext", "a"); err != nil {
|
||||||
|
t.Fatalf("settings Remove: %v", err)
|
||||||
|
}
|
||||||
|
if err := settingsStore.RemoveAll("ext"); err != nil {
|
||||||
|
t.Fatalf("settings RemoveAll: %v", err)
|
||||||
|
}
|
||||||
|
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
|
||||||
|
t.Fatalf("settings JSON = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||||
|
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
|
||||||
|
t.Fatalf("reload settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vm := goja.New()
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: "storage-ext",
|
||||||
|
dataDir: filepath.Join(dir, "runtime"),
|
||||||
|
vm: vm,
|
||||||
|
storageFlushDelay: time.Hour,
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
|
||||||
|
t.Fatalf("storage fallback = %q", got)
|
||||||
|
}
|
||||||
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||||
|
t.Fatal("storageSet false")
|
||||||
|
}
|
||||||
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||||
|
t.Fatal("storageSet equal false")
|
||||||
|
}
|
||||||
|
loaded, err := runtime.loadStorage()
|
||||||
|
if err != nil || loaded["key"] == nil {
|
||||||
|
t.Fatalf("loadStorage = %#v/%v", loaded, err)
|
||||||
|
}
|
||||||
|
if err := runtime.flushStorageNow(); err != nil {
|
||||||
|
t.Fatalf("flushStorageNow: %v", err)
|
||||||
|
}
|
||||||
|
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
|
||||||
|
t.Fatal("storageRemove false")
|
||||||
|
}
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
|
||||||
|
t.Fatal("expected storageSet false after close")
|
||||||
|
}
|
||||||
|
|
||||||
|
credRuntime := &extensionRuntime{
|
||||||
|
extensionID: "cred-ext",
|
||||||
|
dataDir: filepath.Join(dir, "creds"),
|
||||||
|
vm: vm,
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
|
||||||
|
t.Fatalf("credentialsStore = %#v", result)
|
||||||
|
}
|
||||||
|
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
|
||||||
|
t.Fatalf("credential = %q", got)
|
||||||
|
}
|
||||||
|
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
||||||
|
t.Fatal("expected credential")
|
||||||
|
}
|
||||||
|
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
|
||||||
|
t.Fatal("credentialsRemove false")
|
||||||
|
}
|
||||||
|
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
||||||
|
t.Fatal("expected credential removed")
|
||||||
|
}
|
||||||
|
key, err := credRuntime.getEncryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getEncryptionKey: %v", err)
|
||||||
|
}
|
||||||
|
encrypted, err := encryptAES([]byte("plain"), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encryptAES: %v", err)
|
||||||
|
}
|
||||||
|
decrypted, err := decryptAES(encrypted, key)
|
||||||
|
if err != nil || string(decrypted) != "plain" {
|
||||||
|
t.Fatalf("decryptAES = %q/%v", decrypted, err)
|
||||||
|
}
|
||||||
|
if _, err := decryptAES([]byte("short"), key); err == nil {
|
||||||
|
t.Fatal("expected short ciphertext error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
jar, _ := newSimpleCookieJar()
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: "http-ext",
|
||||||
|
manifest: &ExtensionManifest{
|
||||||
|
Name: "http-ext",
|
||||||
|
Description: "HTTP extension",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vm: vm,
|
||||||
|
cookieJar: jar,
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
var body []byte
|
||||||
|
if req.Body != nil {
|
||||||
|
body, _ = io.ReadAll(req.Body)
|
||||||
|
}
|
||||||
|
header := make(http.Header)
|
||||||
|
header.Set("X-Method", req.Method)
|
||||||
|
if req.URL.Path == "/huge" {
|
||||||
|
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 201,
|
||||||
|
Header: header,
|
||||||
|
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||||
|
t.Fatalf("validateDomain allowed: %v", err)
|
||||||
|
}
|
||||||
|
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
|
||||||
|
if err := runtime.validateDomain(rawURL); err == nil {
|
||||||
|
t.Fatalf("expected domain validation error for %s", rawURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
|
||||||
|
t.Fatalf("httpGet = %#v", got)
|
||||||
|
}
|
||||||
|
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
|
||||||
|
t.Fatalf("httpPost = %#v", got)
|
||||||
|
}
|
||||||
|
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
|
||||||
|
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
|
||||||
|
t.Fatalf("httpRequest = %#v", got)
|
||||||
|
}
|
||||||
|
for _, method := range []struct {
|
||||||
|
name string
|
||||||
|
call func(goja.FunctionCall) goja.Value
|
||||||
|
args []goja.Value
|
||||||
|
}{
|
||||||
|
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
|
||||||
|
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
|
||||||
|
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
|
||||||
|
} {
|
||||||
|
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
|
||||||
|
t.Fatalf("%s = %#v", method.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
|
||||||
|
t.Fatalf("huge response = %#v", got)
|
||||||
|
}
|
||||||
|
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("expected cookies cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
|
||||||
|
t.Fatal("missing string compare args should be zero")
|
||||||
|
}
|
||||||
|
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
|
||||||
|
t.Fatal("expected exact string similarity")
|
||||||
|
}
|
||||||
|
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
|
||||||
|
t.Fatal("expected duration match")
|
||||||
|
}
|
||||||
|
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
|
||||||
|
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
|
||||||
|
t.Fatal("unexpected genre selection")
|
||||||
|
}
|
||||||
|
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
|
||||||
|
if formatMusicBrainzArtistCredit(credits) != "A & B" {
|
||||||
|
t.Fatal("artist credit format mismatch")
|
||||||
|
}
|
||||||
|
releases := []musicBrainzRelease{
|
||||||
|
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
|
||||||
|
{Title: "Album", ArtistCredit: credits},
|
||||||
|
}
|
||||||
|
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
|
||||||
|
t.Fatal("album artist selection mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
dir := t.TempDir()
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
defer SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
fileBody := "chunk"
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: "file-ext",
|
||||||
|
manifest: &ExtensionManifest{
|
||||||
|
Name: "file-ext",
|
||||||
|
Description: "File extension",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: true,
|
||||||
|
Network: []string{"files.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataDir: dir,
|
||||||
|
vm: vm,
|
||||||
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Header.Get("Range") == "" {
|
||||||
|
body := "downloaded"
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
ContentLength: int64(len(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
rangeHeader := req.Header.Get("Range")
|
||||||
|
start, end := 0, len(fileBody)-1
|
||||||
|
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
|
||||||
|
start, end = 0, 1
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if end >= len(fileBody) {
|
||||||
|
end = len(fileBody) - 1
|
||||||
|
}
|
||||||
|
if start > len(fileBody) {
|
||||||
|
start = len(fileBody)
|
||||||
|
}
|
||||||
|
body := fileBody[start : end+1]
|
||||||
|
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
|
||||||
|
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
runtime.downloadClient = runtime.httpClient
|
||||||
|
|
||||||
|
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
|
||||||
|
t.Fatal("expected file permission error")
|
||||||
|
}
|
||||||
|
if _, err := runtime.validatePath("../escape.txt"); err == nil {
|
||||||
|
t.Fatal("expected sandbox escape error")
|
||||||
|
}
|
||||||
|
AddAllowedDownloadDir(dir)
|
||||||
|
absolutePath := filepath.Join(dir, "allowed.txt")
|
||||||
|
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
|
||||||
|
t.Fatalf("absolute validatePath = %q/%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
|
||||||
|
if write["success"] != true {
|
||||||
|
t.Fatalf("fileWrite = %#v", write)
|
||||||
|
}
|
||||||
|
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
|
||||||
|
t.Fatal("expected written file to exist")
|
||||||
|
}
|
||||||
|
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
|
||||||
|
if read["data"] != "hello" {
|
||||||
|
t.Fatalf("fileRead = %#v", read)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("nested/bytes.bin"),
|
||||||
|
vm.ToValue("4869"),
|
||||||
|
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if writeBytes["success"] != true {
|
||||||
|
t.Fatalf("fileWriteBytes = %#v", writeBytes)
|
||||||
|
}
|
||||||
|
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("nested/bytes.bin"),
|
||||||
|
vm.ToValue([]interface{}{float64('!')}),
|
||||||
|
vm.ToValue(map[string]interface{}{"append": true}),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if appendBytes["success"] != true {
|
||||||
|
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
|
||||||
|
}
|
||||||
|
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("nested/bytes.bin"),
|
||||||
|
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
|
||||||
|
t.Fatalf("fileReadBytes = %#v", readBytes)
|
||||||
|
}
|
||||||
|
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("nested/bad.bin"),
|
||||||
|
vm.ToValue("x"),
|
||||||
|
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
|
||||||
|
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||||
|
t.Fatalf("expected append+offset failure, got %#v", bad)
|
||||||
|
}
|
||||||
|
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("nested/bytes.bin"),
|
||||||
|
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
|
||||||
|
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||||
|
t.Fatalf("expected bad encoding failure, got %#v", bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
|
||||||
|
if copyResult["success"] != true {
|
||||||
|
t.Fatalf("fileCopy = %#v", copyResult)
|
||||||
|
}
|
||||||
|
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||||
|
if moveResult["success"] != true {
|
||||||
|
t.Fatalf("fileMove = %#v", moveResult)
|
||||||
|
}
|
||||||
|
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||||
|
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
|
||||||
|
t.Fatalf("fileGetSize = %#v", sizeResult)
|
||||||
|
}
|
||||||
|
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||||
|
if deleteResult["success"] != true {
|
||||||
|
t.Fatalf("fileDelete = %#v", deleteResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("https://files.example.com/file"),
|
||||||
|
vm.ToValue("downloads/file.bin"),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if download["success"] != true {
|
||||||
|
t.Fatalf("fileDownload = %#v", download)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
|
||||||
|
t.Fatalf("downloaded data = %q/%v", data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
vm.ToValue("https://files.example.com/chunk"),
|
||||||
|
vm.ToValue("downloads/chunk.bin"),
|
||||||
|
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
|
||||||
|
}}).Export().(map[string]interface{})
|
||||||
|
if chunked["success"] != true {
|
||||||
|
t.Fatalf("chunked fileDownload = %#v", chunked)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
|
||||||
|
t.Fatalf("chunked data = %q/%v", data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
|
||||||
|
t.Fatalf("expected missing download args error, got %#v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
|
||||||
|
|
||||||
|
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
|
||||||
|
t.Fatal("expected sha256")
|
||||||
|
}
|
||||||
|
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
||||||
|
t.Fatal("expected hmac sha256")
|
||||||
|
}
|
||||||
|
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
||||||
|
t.Fatal("expected hmac sha256 base64")
|
||||||
|
}
|
||||||
|
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
|
||||||
|
t.Fatal("expected hmac sha1 bytes")
|
||||||
|
}
|
||||||
|
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
|
||||||
|
t.Fatal("expected invalid JSON to return undefined")
|
||||||
|
}
|
||||||
|
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
|
||||||
|
if parsed["ok"] != true {
|
||||||
|
t.Fatalf("parseJSON = %#v", parsed)
|
||||||
|
}
|
||||||
|
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
|
||||||
|
t.Fatalf("stringifyJSON = %q", text)
|
||||||
|
}
|
||||||
|
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||||
|
if encrypted["success"] != true || encrypted["data"] == "" {
|
||||||
|
t.Fatalf("cryptoEncrypt = %#v", encrypted)
|
||||||
|
}
|
||||||
|
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||||
|
if decrypted["success"] != true || decrypted["data"] != "plain" {
|
||||||
|
t.Fatalf("cryptoDecrypt = %#v", decrypted)
|
||||||
|
}
|
||||||
|
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||||
|
t.Fatalf("expected bad decrypt failure, got %#v", bad)
|
||||||
|
}
|
||||||
|
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
|
||||||
|
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
|
||||||
|
t.Fatalf("cryptoGenerateKey = %#v", key)
|
||||||
|
}
|
||||||
|
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
|
||||||
|
t.Fatal("expected user agents")
|
||||||
|
}
|
||||||
|
SetAppVersion("9.9.9")
|
||||||
|
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
|
||||||
|
t.Fatal("appVersion mismatch")
|
||||||
|
}
|
||||||
|
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
|
||||||
|
t.Fatal("zero sleep should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemID := "utils-item"
|
||||||
|
runtime.setActiveDownloadItemID(itemID)
|
||||||
|
initDownloadCancel(itemID)
|
||||||
|
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("item should not be cancelled yet")
|
||||||
|
}
|
||||||
|
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
|
||||||
|
cancelDownload(itemID)
|
||||||
|
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("item should be cancelled")
|
||||||
|
}
|
||||||
|
clearDownloadCancel(itemID)
|
||||||
|
runtime.clearActiveDownloadItemID()
|
||||||
|
|
||||||
|
requestID := "utils-request"
|
||||||
|
runtime.setActiveRequestID(requestID)
|
||||||
|
initExtensionRequestCancel(requestID)
|
||||||
|
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("request should not be cancelled yet")
|
||||||
|
}
|
||||||
|
cancelExtensionRequest(requestID)
|
||||||
|
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
||||||
|
t.Fatal("request should be cancelled")
|
||||||
|
}
|
||||||
|
clearExtensionRequestCancel(requestID)
|
||||||
|
runtime.clearActiveRequestID()
|
||||||
|
|
||||||
|
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
|
||||||
|
t.Fatalf("formatLogArgs = %q", msg)
|
||||||
|
}
|
||||||
|
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
|
||||||
|
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
|
||||||
|
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
|
||||||
|
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
|
||||||
|
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
|
||||||
|
t.Fatalf("sanitize wrapper = %q", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,96 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue(GetAppVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue(appUserAgent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
sleepMs := 0
|
||||||
|
switch value := call.Arguments[0].Export().(type) {
|
||||||
|
case int64:
|
||||||
|
sleepMs = int(value)
|
||||||
|
case int32:
|
||||||
|
sleepMs = int(value)
|
||||||
|
case int:
|
||||||
|
sleepMs = value
|
||||||
|
case float64:
|
||||||
|
sleepMs = int(value)
|
||||||
|
default:
|
||||||
|
sleepMs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if sleepMs <= 0 {
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
if sleepMs > 5*60*1000 {
|
||||||
|
sleepMs = 5 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
itemID := r.getActiveDownloadItemID()
|
||||||
|
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if itemID != "" && isDownloadCancelled(itemID) {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
step := 100 * time.Millisecond
|
||||||
|
if remaining < step {
|
||||||
|
step = remaining
|
||||||
|
}
|
||||||
|
time.Sleep(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
|
||||||
|
itemID := r.getActiveDownloadItemID()
|
||||||
|
if itemID == "" {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
|
||||||
|
requestID := r.getActiveRequestID()
|
||||||
|
if requestID == "" {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
|
||||||
|
itemID := r.getActiveDownloadItemID()
|
||||||
|
if itemID == "" || len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
switch status {
|
||||||
|
case itemProgressStatusPreparing:
|
||||||
|
SetItemPreparing(itemID)
|
||||||
|
case itemProgressStatusDownloading:
|
||||||
|
SetItemDownloading(itemID)
|
||||||
|
case itemProgressStatusFinalizing:
|
||||||
|
SetItemFinalizing(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
@@ -324,6 +414,83 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
"bitDepth": quality.BitDepth,
|
"bitDepth": quality.BitDepth,
|
||||||
"sampleRate": quality.SampleRate,
|
"sampleRate": quality.SampleRate,
|
||||||
"totalSamples": quality.TotalSamples,
|
"totalSamples": quality.TotalSamples,
|
||||||
|
"duration": quality.Duration,
|
||||||
|
"codec": quality.Codec,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 3 {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "spotifyID, trackName, and artistName are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyID := strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
trackName := strings.TrimSpace(call.Arguments[1].String())
|
||||||
|
artistName := strings.TrimSpace(call.Arguments[2].String())
|
||||||
|
filePath := ""
|
||||||
|
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||||
|
filePath = strings.TrimSpace(call.Arguments[3].String())
|
||||||
|
}
|
||||||
|
var durationMs int64
|
||||||
|
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
|
||||||
|
durationMs = call.Arguments[4].ToInteger()
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"lyrics": lyrics,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "outputDir and isrc are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||||
|
if outputDir == "" || isrc == "" {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "outputDir and isrc are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"exists": exists,
|
||||||
|
"filePath": filePath,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 3 {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "outputDir, isrc, and filePath are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||||
|
filePath := strings.TrimSpace(call.Arguments[2].String())
|
||||||
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "outputDir, isrc, and filePath are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
AddToISRCIndex(outputDir, isrc, filePath)
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
const signedSessionRefreshSkew = time.Hour
|
||||||
|
|
||||||
|
var (
|
||||||
|
pendingSignedSessionGrants = make(map[string]string)
|
||||||
|
pendingSignedSessionGrantsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type signedSessionRecord struct {
|
||||||
|
InstallID string `json:"install_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
AppVersion string `json:"app_version,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signedSessionExchangeResponse struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
ChallengeID string `json:"challenge_id,omitempty"`
|
||||||
|
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||||
|
AuthURL string `json:"auth_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||||
|
if config == nil {
|
||||||
|
return SignedSessionConfig{}
|
||||||
|
}
|
||||||
|
resolved := *config
|
||||||
|
if resolved.AppVersion == "" {
|
||||||
|
resolved.AppVersion = "ext-1.0"
|
||||||
|
}
|
||||||
|
if resolved.Platform == "" {
|
||||||
|
resolved.Platform = "extension"
|
||||||
|
}
|
||||||
|
if resolved.CallbackURL == "" {
|
||||||
|
resolved.CallbackURL = "spotiflac://session-grant"
|
||||||
|
}
|
||||||
|
if resolved.SchemeLabel == "" {
|
||||||
|
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||||
|
}
|
||||||
|
if resolved.HeaderPrefix == "" {
|
||||||
|
resolved.HeaderPrefix = "X-Sig-"
|
||||||
|
}
|
||||||
|
if resolved.TimeWindowSeconds <= 0 {
|
||||||
|
resolved.TimeWindowSeconds = 300
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Bootstrap == "" {
|
||||||
|
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Challenge == "" {
|
||||||
|
resolved.Endpoints.Challenge = "/challenge"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Exchange == "" {
|
||||||
|
resolved.Endpoints.Exchange = "/session/exchange"
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
return "", fmt.Errorf("signed session namespace is empty")
|
||||||
|
}
|
||||||
|
baseDir := filepath.Dir(r.dataDir)
|
||||||
|
if baseDir == "." || baseDir == "" {
|
||||||
|
baseDir = r.dataDir
|
||||||
|
}
|
||||||
|
dir := filepath.Join(baseDir, "signed_sessions")
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scope := strings.Join([]string{
|
||||||
|
namespace,
|
||||||
|
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.AppVersion)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.Platform)),
|
||||||
|
}, "\n")
|
||||||
|
sum := sha256.Sum256([]byte(scope))
|
||||||
|
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||||
|
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range namespace {
|
||||||
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||||
|
b.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), ".-_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := &signedSessionRecord{}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(data, record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.InstallID) == "" {
|
||||||
|
record.InstallID = randomHex(16)
|
||||||
|
}
|
||||||
|
normalizeSignedSessionRecordScope(config, record)
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
baseURL := strings.TrimSpace(config.BaseURL)
|
||||||
|
appVersion := strings.TrimSpace(config.AppVersion)
|
||||||
|
platform := strings.TrimSpace(config.Platform)
|
||||||
|
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.Namespace != namespace ||
|
||||||
|
record.BaseURL != baseURL ||
|
||||||
|
record.AppVersion != appVersion ||
|
||||||
|
record.Platform != platform {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
}
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(record, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(bytesLen int) string {
|
||||||
|
buf := make([]byte, bytesLen)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.000Z",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
||||||
|
authenticated = false
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"authenticated": authenticated,
|
||||||
|
"expires_at": record.ExpiresAt,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"session_id": record.SessionID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||||
|
grant := ""
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
grant = pendingSignedSessionGrants[r.extensionID]
|
||||||
|
delete(pendingSignedSessionGrants, r.extensionID)
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
||||||
|
}
|
||||||
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"grant": grant,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var exchanged signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||||
|
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||||
|
return fmt.Errorf("session exchange response missing session fields")
|
||||||
|
}
|
||||||
|
record.SessionID = exchanged.SessionID
|
||||||
|
record.SessionSecret = exchanged.SessionSecret
|
||||||
|
record.ExpiresAt = exchanged.ExpiresAt
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
||||||
|
}
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
requestPath := call.Arguments[1].String()
|
||||||
|
body := []byte{}
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
switch v := call.Arguments[2].Export().(type) {
|
||||||
|
case string:
|
||||||
|
body = []byte(v)
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
encoded, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
body = encoded
|
||||||
|
default:
|
||||||
|
body = []byte(call.Arguments[2].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraHeaders := map[string]string{}
|
||||||
|
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||||
|
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
extraHeaders[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := r.ensureSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
|
"body": string(respBody),
|
||||||
|
"headers": respHeaders,
|
||||||
|
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"needsVerification": true,
|
||||||
|
"error": "VERIFY_REQUIRED",
|
||||||
|
"open_auth_url": authURL,
|
||||||
|
"auth_url": authURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if record.SessionID == "" || record.SessionSecret == "" {
|
||||||
|
return nil, fmt.Errorf("signed session is not authenticated")
|
||||||
|
}
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return nil, fmt.Errorf("signed session expired")
|
||||||
|
}
|
||||||
|
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||||
|
_ = r.refreshSignedSession(config, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||||
|
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var refreshed signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
if refreshed.SessionID != "" {
|
||||||
|
record.SessionID = refreshed.SessionID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.SessionSecret != "" {
|
||||||
|
record.SessionSecret = refreshed.SessionSecret
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||||
|
record.ExpiresAt = refreshed.ExpiresAt
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(bootstrapURL)
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("app_version", config.AppVersion)
|
||||||
|
query.Set("install_id", record.InstallID)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||||
|
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var boot signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(body, &boot); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||||
|
record.SessionID = boot.SessionID
|
||||||
|
record.SessionSecret = boot.SessionSecret
|
||||||
|
record.ExpiresAt = boot.ExpiresAt
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
authURL := boot.AuthURL
|
||||||
|
if authURL == "" && boot.ChallengeURL != "" {
|
||||||
|
authURL = boot.ChallengeURL
|
||||||
|
}
|
||||||
|
if authURL == "" && boot.ChallengeID != "" {
|
||||||
|
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||||
|
}
|
||||||
|
if authURL != "" {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
CallbackURL: config.CallbackURL,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
}
|
||||||
|
return authURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||||
|
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(challengeURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
callback, err := url.Parse(config.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q := callback.Query()
|
||||||
|
q.Set("cb_version", "v2grant")
|
||||||
|
q.Set("state", r.extensionID)
|
||||||
|
callback.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("id", challengeID)
|
||||||
|
query.Set("cb", callback.String())
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||||
|
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||||
|
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||||
|
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimSpace(endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", fmt.Errorf("signed session endpoint is empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(endpoint, "https://") {
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimLeft(endpoint, "/")
|
||||||
|
ref, _ := url.Parse(endpoint)
|
||||||
|
return base.ResolveReference(ref).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) doSignedSessionRequest(
|
||||||
|
config SignedSessionConfig,
|
||||||
|
record *signedSessionRecord,
|
||||||
|
method string,
|
||||||
|
requestPath string,
|
||||||
|
body []byte,
|
||||||
|
extraHeaders map[string]string,
|
||||||
|
) (*http.Response, []byte, map[string]interface{}, error) {
|
||||||
|
fullURL, err := signedSessionURL(config, requestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
nonce := randomHex(12)
|
||||||
|
bodyHashBytes := sha256.Sum256(body)
|
||||||
|
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||||
|
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||||
|
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||||
|
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||||
|
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||||
|
signingInput := strings.Join([]string{
|
||||||
|
config.SchemeLabel,
|
||||||
|
method,
|
||||||
|
parsed.EscapedPath(),
|
||||||
|
"",
|
||||||
|
bodyHash,
|
||||||
|
ts,
|
||||||
|
nonce,
|
||||||
|
record.SessionID,
|
||||||
|
config.AppVersion,
|
||||||
|
config.Platform,
|
||||||
|
}, "\n")
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if len(body) > 0 {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
prefix := config.HeaderPrefix
|
||||||
|
req.Header.Set(prefix+"Session", record.SessionID)
|
||||||
|
req.Header.Set(prefix+"Timestamp", ts)
|
||||||
|
req.Header.Set(prefix+"Nonce", nonce)
|
||||||
|
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||||
|
req.Header.Set(prefix+"Signature", sig)
|
||||||
|
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||||
|
req.Header.Set(prefix+"Platform", config.Platform)
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
headers := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
headers[k] = v[0]
|
||||||
|
} else {
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, respBody, headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionRetryAfterSeconds(resp *http.Response) int {
|
||||||
|
if resp == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seconds, err := strconv.Atoi(value); err == nil {
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
if retryAt, err := http.ParseTime(value); err == nil {
|
||||||
|
seconds := int(time.Until(retryAt).Seconds())
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write(message)
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||||
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
|
grant = strings.TrimSpace(grant)
|
||||||
|
if extensionID == "" || grant == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
pendingSignedSessionGrants[extensionID] = grant
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ type storeExtension struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url"`
|
DownloadURL string `json:"download_url"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
|
|||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
Version: e.Version,
|
Version: e.Version,
|
||||||
Author: e.Author,
|
|
||||||
Description: e.Description,
|
Description: e.Description,
|
||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
|||||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
resp, err := client.Get(s.registryURL)
|
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
if s.cache != nil {
|
||||||
|
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to build registry request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
|
req.Header.Set("Pragma", "no-cache")
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
@@ -323,22 +330,26 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
|
||||||
registry, err := s.fetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *storeExtension
|
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext := e
|
||||||
break
|
return &ext, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext == nil {
|
return nil, fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
}
|
||||||
|
|
||||||
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
|
ext, err := s.findExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
@@ -348,7 +359,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
|||||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||||
resp, err := client.Get(ext.getDownloadURL())
|
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build download request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
|
req.Header.Set("Pragma", "no-cache")
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download: %w", err)
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
}
|
}
|
||||||
@@ -481,8 +498,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
|||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) {
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -12,7 +16,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
|||||||
"name": "test-provider",
|
"name": "test-provider",
|
||||||
"displayName": "Test Provider",
|
"displayName": "Test Provider",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Test Author",
|
|
||||||
"description": "A test extension",
|
"description": "A test extension",
|
||||||
"type": ["metadata_provider"],
|
"type": ["metadata_provider"],
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -43,10 +46,26 @@ func TestParseManifest_Valid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtensionManifestStopsProviderFallback(t *testing.T) {
|
||||||
|
modernManifest := &ExtensionManifest{StopProviderFallback: true}
|
||||||
|
if !modernManifest.StopsProviderFallback() {
|
||||||
|
t.Fatal("expected stopProviderFallback to stop provider fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyManifest := &ExtensionManifest{SkipBuiltInFallback: true}
|
||||||
|
if !legacyManifest.StopsProviderFallback() {
|
||||||
|
t.Fatal("expected legacy skipBuiltInFallback to stop provider fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultManifest := &ExtensionManifest{}
|
||||||
|
if defaultManifest.StopsProviderFallback() {
|
||||||
|
t.Fatal("expected default manifest to allow provider fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseManifest_MissingName(t *testing.T) {
|
func TestParseManifest_MissingName(t *testing.T) {
|
||||||
invalidManifest := `{
|
invalidManifest := `{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Test Author",
|
|
||||||
"description": "A test extension",
|
"description": "A test extension",
|
||||||
"type": ["metadata_provider"]
|
"type": ["metadata_provider"]
|
||||||
}`
|
}`
|
||||||
@@ -61,7 +80,6 @@ func TestParseManifest_MissingType(t *testing.T) {
|
|||||||
invalidManifest := `{
|
invalidManifest := `{
|
||||||
"name": "test-provider",
|
"name": "test-provider",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Test Author",
|
|
||||||
"description": "A test extension"
|
"description": "A test extension"
|
||||||
}`
|
}`
|
||||||
|
|
||||||
@@ -98,7 +116,6 @@ func TestIsDomainAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
// Create a mock extension with limited network permissions
|
|
||||||
ext := &loadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
@@ -127,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||||
t.Error("Expected notallowed.com to be denied")
|
t.Error("Expected notallowed.com to be denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
|
||||||
|
t.Error("Expected http URL to be denied without allowHttp")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Manifest.Permissions.AllowHTTP = true
|
||||||
|
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
@@ -235,14 +261,176 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stringifyJSON failed: %v", err)
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
}
|
}
|
||||||
// JSON output may vary in order, just check it's valid
|
|
||||||
if result.String() == "" {
|
if result.String() == "" {
|
||||||
t.Error("Expected non-empty JSON string")
|
t.Error("Expected non-empty JSON string")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.sleep(1)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sleep failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.ToBoolean() {
|
||||||
|
t.Error("Expected sleep to complete successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.setActiveDownloadItemID("test-item")
|
||||||
|
cancelDownload("test-item")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
clearDownloadCancel("test-item")
|
||||||
|
runtime.clearActiveDownloadItemID()
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.isDownloadCancelled()`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("isDownloadCancelled failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.ToBoolean() {
|
||||||
|
t.Error("Expected active download cancellation to be visible to JS")
|
||||||
|
}
|
||||||
|
|
||||||
|
SetAppVersion("4.2.2")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetAppVersion("")
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.appVersion()`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appVersion failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := result.String(); got != "4.2.2" {
|
||||||
|
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.appUserAgent()`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appUserAgent failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
|
||||||
|
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.sleep(50)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cancel-aware sleep failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.ToBoolean() {
|
||||||
|
t.Error("Expected sleep to abort when download is cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
runtime.setActiveDownloadItemID("test-item")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
clearDownloadCancel("test-item")
|
||||||
|
runtime.clearActiveDownloadItemID()
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = runtime.bindDownloadCancelContext(req)
|
||||||
|
cancelDownload("test-item")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("Expected bound request context to be cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Context().Err() == nil {
|
||||||
|
t.Fatal("Expected request context error after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
runtime.setActiveDownloadItemID("test-item")
|
||||||
|
cancelDownload("test-item")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
clearDownloadCancel("test-item")
|
||||||
|
runtime.clearActiveDownloadItemID()
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = runtime.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("Expected pre-cancelled request context to stay cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Context().Err() == nil {
|
||||||
|
t.Fatal("Expected request context error for pre-cancelled item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
|
||||||
|
if !errors.Is(err, ErrExtensionRequestCancelled) {
|
||||||
|
t.Fatalf("expected extension request cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
|
const requestID = "test-extension-request"
|
||||||
|
clearExtensionRequestCancel(requestID)
|
||||||
|
defer clearExtensionRequestCancel(requestID)
|
||||||
|
|
||||||
|
runtime.setActiveRequestID(requestID)
|
||||||
|
defer runtime.clearActiveRequestID()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req = runtime.bindDownloadCancelContext(req)
|
||||||
|
|
||||||
|
cancelExtensionRequest(requestID)
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected request context to be cancelled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
// Create extension with limited network permissions
|
|
||||||
ext := &loadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
if vm == nil {
|
if vm == nil {
|
||||||
return nil, fmt.Errorf("extension runtime unavailable")
|
return nil, fmt.Errorf("extension runtime unavailable")
|
||||||
}
|
}
|
||||||
@@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
@@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.value, res.err
|
return res.value, res.err
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
cancelled := ctx.Err() == context.Canceled
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
interrupted = true
|
interrupted = true
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
if cancelled {
|
||||||
|
vm.Interrupt("extension request cancelled")
|
||||||
|
} else {
|
||||||
|
vm.Interrupt("execution timeout")
|
||||||
|
}
|
||||||
|
|
||||||
// MUST wait for the goroutine to finish before returning.
|
// MUST wait for the goroutine to finish before returning.
|
||||||
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||||
@@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
// pointer dereference.
|
// pointer dereference.
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
|
if cancelled {
|
||||||
|
return nil, ErrExtensionRequestCancelled
|
||||||
|
}
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
return nil, res.err
|
return nil, res.err
|
||||||
}
|
}
|
||||||
@@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
// Log a warning — the VM should NOT be reused after this.
|
// Log a warning — the VM should NOT be reused after this.
|
||||||
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
|
if cancelled {
|
||||||
|
return nil, ErrExtensionRequestCancelled
|
||||||
|
}
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||||
// This should be used when you want to continue using the VM after a timeout
|
// This should be used when you want to continue using the VM after a timeout
|
||||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
result, err := RunWithTimeout(vm, script, timeout)
|
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
|
||||||
|
|
||||||
if vm != nil {
|
if vm != nil {
|
||||||
vm.ClearInterrupt()
|
vm.ClearInterrupt()
|
||||||
|
|||||||
@@ -6,35 +6,79 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
multiUnderscore = regexp.MustCompile(`_+`)
|
multiUnderscore = regexp.MustCompile(`_+`)
|
||||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
|
||||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := strings.ReplaceAll(filename, "/", " ")
|
||||||
|
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
||||||
|
|
||||||
|
var builder 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
|
||||||
|
}
|
||||||
|
builder.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized = builder.String()
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ". ")
|
||||||
|
sanitized = strings.Join(strings.Fields(sanitized), " ")
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
sanitized = strings.Trim(sanitized, "_ ")
|
||||||
|
|
||||||
|
if !utf8.ValidString(sanitized) {
|
||||||
|
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||||
|
}
|
||||||
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = sanitized[:200]
|
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||||
|
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||||
|
sanitized = strings.Trim(sanitized, "_ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
sanitized = "untitled"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||||
|
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
used := 0
|
||||||
|
for i, r := range value {
|
||||||
|
runeLen := utf8.RuneLen(r)
|
||||||
|
if runeLen < 0 {
|
||||||
|
runeLen = len(string(r))
|
||||||
|
}
|
||||||
|
if used+runeLen > maxBytes {
|
||||||
|
return value[:i]
|
||||||
|
}
|
||||||
|
used += runeLen
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||||
if template == "" {
|
if template == "" {
|
||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
@@ -55,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||||
|
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
|
||||||
"{year}": yearValue,
|
"{year}": yearValue,
|
||||||
"{date}": dateValue,
|
"{date}": dateValue,
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
@@ -76,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
number := getInt(metadata, parts[1])
|
number := getInt(metadata, parts[1])
|
||||||
|
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
|
||||||
|
number = getPlaylistPosition(metadata)
|
||||||
|
}
|
||||||
width, err := strconv.Atoi(parts[2])
|
width, err := strconv.Atoi(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -133,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
candidateKeys = append(candidateKeys, "track_number")
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case "disc":
|
case "disc":
|
||||||
candidateKeys = append(candidateKeys, "disc_number")
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
|
case "playlist_position", "playlistPosition", "playlist position", "position":
|
||||||
|
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidateKeys {
|
for _, candidate := range candidateKeys {
|
||||||
@@ -156,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPlaylistPosition(metadata map[string]interface{}) int {
|
||||||
|
return getInt(metadata, "playlist_position")
|
||||||
|
}
|
||||||
|
|
||||||
func formatTrackNumber(n int) string {
|
func formatTrackNumber(n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
@@ -51,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"playlist_position": 4,
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{playlist_position:02} - {artist} - {title}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
expected := "04 - Artist Name - Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"artist": "Artist Name",
|
"artist": "Artist Name",
|
||||||
@@ -83,3 +104,28 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
|
|||||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
|
||||||
|
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
|
||||||
|
want := "Text In Quotes % Demo"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||||
|
got := sanitizeFilename(`<>:"/\|?*`)
|
||||||
|
if got != "Unknown" {
|
||||||
|
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
|
||||||
|
got := sanitizeFilename(strings.Repeat("あ", 80))
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
|
||||||
|
}
|
||||||
|
if len(got) > 200 {
|
||||||
|
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.25.8
|
toolchain go1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
|
||||||
golang.org/x/text v0.35.0
|
golang.org/x/net v0.56.0
|
||||||
|
golang.org/x/text v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
@@ -16,12 +16,14 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
|
|||||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
|
||||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||