Repository: yne/dzr Branch: master Commit: e2e059468f72 Files: 13 Total size: 57.8 KB Directory structure: gitextract_rsjai3cw/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── ci.yml ├── LICENSE ├── README.md ├── dzr ├── dzr-dec ├── dzr-id3 ├── dzr-srt ├── dzr-url ├── extension/ │ ├── main.js │ ├── package.json │ └── webview.html └── index.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: yne ================================================ FILE: .github/workflows/ci.yml ================================================ name: ci on: push: branches: - master jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: check key extract run: | [ "$(DZR_CBC= ./dzr ! 2>/dev/null | sha1sum)" = '3ad58d9232a3745ad9308b0669c83b6f7e8dba4d -' ] - name: bundle VSIX run: zip -r $(jq -r '.name+"-"+.version+".vsix"' < extension/package.json) extension - name: publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_FILES: dzr* run: | RELEASE_TAG="$(date +%y%m%d)" curl -s -XPOST -d '{"tag_name": "'$RELEASE_TAG'"}' \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H 'Content-Type: application/json' \ "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases" || : RELEASE_ID=$(curl -s https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/$RELEASE_TAG | jq .id) echo tag=$RELEASE_TAG has id=$RELEASE_ID for RELEASE_FILE in $RELEASE_FILES; do curl -s -XPOST -T $RELEASE_FILE \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type:application/octet-stream" \ "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$RELEASE_FILE" || : done ================================================ FILE: LICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: README.md ================================================ ![dzr logo](.github/.logo.svg) # DZR: the command line deezer.com player ## Features - Cross-platform support: Linux, *BSD, MacOS, Android, Windows+WSL - Little dependencies: `curl`, `jq`, `dialog`, `openssl` (`openssl-tool` in Android) - Real-time Lyrics display - Web interface support (see [dzr](https://github.com/topics/dzr)-tagged frontend) - ID3v2 tag injector from Deezer metadata (cover, artist, ...) - Play without storing/caching on your machine for [legal reasons](https://github.com/github/dmca/blob/master/2021/02/2021-02-10-deezer.md) - No private deezer key in the source (auto-extracted from web player, also for legal reasons) - VSCode extension [VSIX](https://github.com/yne/dzr/releases) experimental port ## Preview (CLI) [![asciicast](https://asciinema.org/a/406758.svg)](https://asciinema.org/a/406758) ## Preview (VSIX) ![Screenshot](https://github.com/yne/dzr/assets/5113053/37b6cd26-8876-4d77-92bb-293ff248e21d) ## Install | Platform | command | version | |----------|---------|---------| | MacOS + [brew](https://formulae.brew.sh/formula/dzr) | `brew install dzr` | ![](https://repology.org/badge/version-for-repo/homebrew/dzr.svg?header=) | Arch Linux + [AUR](https://aur.archlinux.org/packages/dzr) | `yay -S dzr` | ![](https://repology.org/badge/version-for-repo/aur/dzr.svg?header=) | Gentoo + [GURU](https://github.com/gentoo/guru) | `emerge --ask dzr` | ![](https://repology.org/badge/version-for-repo/gentoo_ovl_guru/dzr.svg?header=) | Ubuntu + [Snap](https://snapcraft.io/dzr) | `snap install --edge dzr` | [Help Me](https://github.com/yne/dzr/issues/25) | Linux + [Flatpak](https://www.flatpak.org/) | `flatpak install dzr` | [Help Me](https://github.com/yne/dzr/issues/25) | Nix + [Flake](https://wiki.nixos.org/wiki/flakes) | `nix run github.com/yne/dzr` | [Help Me](https://github.com/yne/dzr/issues/25) | Android + [Termux](https://f-droid.org/packages/com.termux/) | `curl -sL github.com/yne/dzr/archive/master.tar.gz \| tar xzf -`
`mv dzr-master/dzr* $PREFIX/bin` | [![](https://img.shields.io/badge/-tar.gz-40c010?logo=hackthebox)](https://github.com/yne/dzr/archive/master.tar.gz) | VSCode | `code --install-extension ./path/to/dzr-*.vsix` | [![](https://img.shields.io/badge/VSIX-4c1?logo=visualstudiocode)](https://github.com/yne/dzr/releases) ## Usage ```sh # browse api.deezer.com dzr # browse a specific api.deezer.com url dzr /artist/860 # play a specific track dzr /track/1043317462 # use a custom PLAYER (mpg123 v1.31+ is a lightweight alternative) PLAYER="mpg123 -" dzr # inject deezer ID3v2 into MP3 (require eyeD3) and rename it as $ARTIST - $TITLE.mp3 dzr-id3 https://deezer.com/track/1043317462 tagme.mp3 # show track lyrics as srt dzr-srt https://deezer.com/track/14408104 # play track with it lyrics PLAYER='dzr-srt $id > .srt ; mpv --sub-file=.srt -' dzr /track/14408104 # play track with it srt (using non-POSIX compliant process substitution) PLAYER='mpv --sub-file=<(dzr-srt $id) -' dzr /track/14408104 # install dzr into ./cgi-bin/. Then serve it mkdir -p ./cgi-bin/ && install dzr* ./cgi-bin/ python3 -m http.server --cgi open http://127.0.0.1:8000/cgi-bin/dzr?6113114 ``` ================================================ FILE: dzr ================================================ #!/bin/sh # CGI call handling, for example moving ./dzr* bin to ~/cgi-bin and running : # python3 -m http.server --cgi # from your ~ (as non-root !) will reply to http://0.0.0.0:8000/cgi-bin/dzr?6113114,806027 FETCH=${FETCH:-curl -s} # FETCH="wget -q -O -" or FETCH="curl -s -k" PLAYER=${PLAYER:-'mpv -'} # PLAYER="ffplay -" DZR_SPONGE=${DZR_SPONGE:-cat -} # DZR_SPONGE="sponge" from package "moreutils" DZR_RC="${DZR_RC:-${XDG_CONFIG_HOME:-$HOME/.config}/dzrrc}" [ -f "$DZR_RC" ] && . "$DZR_RC" && export DZR_CBC DZR_ARL if [ "$REQUEST_METHOD" = "HEAD" ] ; then printf 'Access-Control-Allow-Origin: *\ndzr-api: 0\n\n' elif [ "$REQUEST_METHOD" = "GET" ] ; then printf "Cache-Control: max-age=31536000, immutable\nContent-Type: audio/mpeg\n\n" echo "$QUERY_STRING" | xargs basename | xargs $0-url | while IFS= read -r line; do url=$(printf '%s\n' "$line" | cut -f1) id=$(printf '%s\n' "$line" | cut -f2) $FETCH "$url" | $DZR_SPONGE | $0-dec $id done fi [ ! -z "$REQUEST_METHOD" ] && exit # extraction + warning by charleywright, see: https://github.com/yne/dzr/issues/11 unscramble(){ printf "${8}${16}${7}${15}${6}${14}${5}${13}${4}${12}${3}${11}${2}${10}${1}${9}";} if [ -z "$DZR_CBC" ]; then command -v $(echo "$FETCH" | cut -f 1 -d " ") >/dev/null || { echo "key extraction require $FETCH" 1>&2 ; exit 1 ;} APP_WEB=$($FETCH -L deezer.com/en/channels/explore | sed -n 's/.*src="\([^"]*app-web[^"]*\).*/\1/p' | xargs $FETCH -L) TMP_CBC=$(echo "$APP_WEB" | tr ,_ '\n' | sed -n 's/.*\(%5B0x..%2C.\{39\}%2C0x..%5D\).*/\1/p' | sed 's/%../\n/g' | xargs printf '\\%o ') export DZR_CBC=$(unscramble $TMP_CBC); [ "-$@" = "-!" ] && printf '%s' "$DZR_CBC" && exit mkdir -p "$(dirname "$DZR_RC")" grep -q '^DZR_CBC=' "$DZR_RC" 2>/dev/null && sed -i "s/^DZR_CBC=.*/DZR_CBC=$DZR_CBC/" "$DZR_RC" || echo "DZR_CBC=$DZR_CBC" >> "$DZR_RC" printf "DZR_CBC saved to %s\n" "$DZR_RC" >&2 fi if [ -z "$DZR_ARL" ]; then echo "Starting on 2025-03-08, Anonymous playback are blocked." echo "To authenticate DZR to your account:" echo "- Sign in/up to https://www.deezer.com/" echo "- Then open https://www.deezer.com/desktop/login/electron/callback" echo "- Right click on the 'Open' button, then 'copy link address'." while true; do echo "past the deezer:// link here:" read DZR_ARL export DZR_ARL=$(printf $DZR_ARL | cut -d'/' -f4) echo "$DZR_ARL" | grep -q '^[0-9a-f]\+$' && break; echo "Invalid ARL format (expect: ^[0-9a-f]+$):" echo $DZR_ARL done mkdir -p "$(dirname "$DZR_RC")" grep -q '^DZR_ARL=' "$DZR_RC" 2>/dev/null && sed -i "s/^DZR_ARL=.*/DZR_ARL=$DZR_ARL/" "$DZR_RC" || echo "DZR_ARL=$DZR_ARL" >> "$DZR_RC" printf "DZR_ARL saved to %s\n" "$DZR_RC" >&2 fi # dependencies check, see: https://github.com/yne/dzr/issues/12 for c in jq curl dialog openssl $(echo "$PLAYER" | cut -f 1 -d " "); do command -v $c >/dev/null || { echo "$c is required" 1>&2 ; UNMET_DEPENDENCIES=1;} done [ -n "$UNMET_DEPENDENCIES" ] && exit 1; # fetch user info via gw-light API export GW="https://www.deezer.com/ajax/gw-light.php" gw() { $FETCH "$GW?method=$1&input=3&api_version=1.0&api_token=$DZR_API_TOK" --header "Cookie: sid=$DZR_SID; arl=$DZR_ARL" ${2:+-d "$2"}; } if [ -n "$DZR_ARL" ] && [ -z "$DZR_SID" ]; then export DZR_SID=$($FETCH "www.deezer.com/ajax/gw-light.php?method=deezer.ping&api_version=1.0&api_token" | jq -r .results.SESSION) _DZR_USR=$(DZR_API_TOK= gw deezer.getUserData '{}') export DZR_LOVED=$(printf "%s" "$_DZR_USR" | jq -r .results.USER.LOVEDTRACKS_ID) export DZR_USER_ID=$(printf "%s" "$_DZR_USR" | jq -r .results.USER.USER_ID) export DZR_API_TOK=$(printf "%s" "$_DZR_USR" | jq -r .results.checkForm) export DZR_LIC=$(printf "%s" "$_DZR_USR" | jq -r .results.USER.OPTIONS.license_token) fi # main trap 'printf "\033]0;\007" >&2' EXIT SELF=$(command -v "$0") || SELF="$0" DLG_LIST="dialog --keep-tite --output-fd 1 --menu $1: 0 0 0" DLG_TEXT="dialog --keep-tite --output-fd 1 --inputbox $1: 0 0" FMT_FUNC='s@[,?].*@@;s@[^a-zA-Z0-9]\{1,\}@_@g;s@[0-9,]\{1,\}@0@g;s@_\{1,\}$@@' # jq formatters for gw-light track lists GW_FMT_LIST='(.results.data[]|("/track/"+(.SNG_ID|tostring), (.SNG_TITLE+" - "+.ART_NAME|gsub("\\x22";""))))' GW_FMT_TRKS='"/track/"+([.results.data[]|.SNG_ID]|map(tostring)|join(","))' # -- helpers to extract IDs from paths -- _id() { echo "$1" | sed 's@^/[^/]*/@@;s@/.*@@'; } # /artist/123/top -> 123 _q() { echo "$1" | sed 's/.*q=//'; } # /search/track?q=foo -> foo # -- gw data fetchers (raw JSON, reused by browse + play) -- gw_loved() { gw playlist.getSongs "{\"playlist_id\":\"$DZR_LOVED\",\"start\":0,\"nb\":10000}"; } gw_flow() { gw radio.getUserRadio "{\"user_id\":\"$DZR_USER_ID\"}"; } gw_playlists() { gw deezer.userMenu '{}'; } gw_artists() { gw deezer.pageProfile "{\"user_id\":\"$DZR_USER_ID\",\"tab\":\"artists\",\"start\":0,\"nb\":10000}"; } gw_albums() { gw deezer.pageProfile "{\"user_id\":\"$DZR_USER_ID\",\"tab\":\"albums\",\"start\":0,\"nb\":10000}"; } gw_search_track() { gw search.music "{\"query\":\"$(_q "$1")\",\"filter\":\"ALL\",\"output\":\"TRACK\",\"start\":0,\"nb\":50}"; } gw_search_artist() { gw search.music "{\"query\":\"$(_q "$1")\",\"filter\":\"ALL\",\"output\":\"ARTIST\",\"start\":0,\"nb\":50}"; } gw_search_album() { gw search.music "{\"query\":\"$(_q "$1")\",\"filter\":\"ALL\",\"output\":\"ALBUM\",\"start\":0,\"nb\":50}"; } gw_search_playlist() { gw search.music "{\"query\":\"$(_q "$1")\",\"filter\":\"ALL\",\"output\":\"PLAYLIST\",\"start\":0,\"nb\":50}"; } gw_search_radio() { gw search.music "{\"query\":\"$(_q "$1")\",\"filter\":\"ALL\",\"output\":\"RADIO\",\"start\":0,\"nb\":50}"; } gw_artist_top() { gw artist.getTopTrack "{\"ART_ID\":$(_id "$1"),\"nb\":100}"; } gw_artist_albums() { gw album.getDiscography "{\"ART_ID\":$(_id "$1"),\"nb\":50,\"nb_songs\":0,\"start\":0,\"discography_mode\":\"all\"}"; } gw_artist_related() { gw artist.getRelatedArtist "{\"ART_ID\":$(_id "$1"),\"nb\":25,\"start\":0}"; } gw_artist_radio() { gw radio.getArtistRadio "{\"ART_ID\":$(_id "$1"),\"nb\":40}"; } gw_album() { gw song.getListByAlbum "{\"ALB_ID\":$(_id "$1"),\"nb\":-1}"; } gw_playlist() { gw playlist.getSongs "{\"playlist_id\":\"$(_id "$1")\",\"start\":0,\"nb\":10000}"; } gw_radio() { gw radio.getSongs "{\"RADIO_ID\":$(_id "$1"),\"nb\":40}"; } # -- jq formatters for non-track lists -- FMT_ARTIST='(.results.data[]|("/artist/"+(.ART_ID|tostring), (.ART_NAME|gsub("\\x22";""))))' FMT_ALBUM='(.results.data[]|("/album/"+(.ALB_ID|tostring), ((.ALB_TITLE+" "+.ART_NAME)|gsub("\\x22";""))))' FMT_DISCOG='(.results.data[]|("/album/"+(.ALB_ID|tostring), ((.ALB_TITLE+" ("+(.PHYSICAL_RELEASE_DATE//""|split("-")[0])+")")|gsub("\\x22";""))))' FMT_PLAYLIST='(.results.data[]|("/playlist/"+(.PLAYLIST_ID|tostring), (.TITLE|gsub("\\x22";""))))' FMT_MY_PLAYLIST='(.results.PLAYLISTS.data[]|select(.TYPE!="4")|("/playlist/"+(.PLAYLIST_ID|tostring), ((.TITLE+" ("+(.NB_SONG|tostring)+")")|gsub("\\x22";""))))' FMT_MY_ARTIST='(.results.TAB.artists.data[]|("/artist/"+(.ART_ID|tostring), (.ART_NAME|gsub("\\x22";""))))' FMT_MY_ALBUM='(.results.TAB.albums.data[]|("/album/"+(.ALB_ID|tostring), ((.ALB_TITLE+" "+.ART_NAME)|gsub("\\x22";""))))' FMT_RADIO='(.results.data[]|("/radio/"+(.RADIO_ID|tostring), (.TITLE|gsub("\\x22";""))))' # public API for browse-only endpoints (no gw-light equivalent) API="api.deezer.com" PUB_FMT_GENRE='(.data[]|("/genre/"+(.id|tostring), (.name|gsub("\\x22";""))))' PUB_FMT_RADIO='(.data[]|("/radio/"+(.id|tostring), (.title|gsub("\\x22";""))))' # -- menu functions -- dzr() { $DLG_LIST \ /flow 'my personalised mix' \ /loved 'my favourites' \ /playlists 'my playlists' \ /artists 'my artists' \ /albums 'my albums' \ /search/track?q= 'search track' \ /search/artist?q= 'search artist' \ /search/album?q= 'search album' \ /search/playlist?q= 'search playlist' \ /search/radio?q= 'search radio' \ /genre 'list genres' \ /radio 'list radios' } dzr_flow() { gw_flow | jq "$GW_FMT_LIST" | xargs $DLG_LIST ∞ "continuous flow" ▸ "play all" ⇄ "shuffle" ;} dzr_loved() { gw_loved | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} dzr_playlists() { gw_playlists | jq "$FMT_MY_PLAYLIST" | xargs $DLG_LIST ;} dzr_artists() { gw_artists | jq "$FMT_MY_ARTIST" | xargs $DLG_LIST ;} dzr_albums() { gw_albums | jq "$FMT_MY_ALBUM" | xargs $DLG_LIST ;} # search dzr_search_track() { gw_search_track "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} dzr_search_artist() { gw_search_artist "$1" | jq "$FMT_ARTIST" | xargs $DLG_LIST ;} dzr_search_album() { gw_search_album "$1" | jq "$FMT_ALBUM" | xargs $DLG_LIST ;} dzr_search_playlist() { gw_search_playlist "$1" | jq "$FMT_PLAYLIST" | xargs $DLG_LIST ;} dzr_search_radio() { gw_search_radio "$1" | jq "$FMT_RADIO" | xargs $DLG_LIST ;} # genre dzr_genre() { $FETCH "$API/genre" | jq "$PUB_FMT_GENRE" | xargs $DLG_LIST ;} # public API - no gw-light equivalent dzr_genre_0() { $DLG_LIST radios '' artists '' ;} dzr_genre_0_radios() { $FETCH "$API/genre/$(_id "$1")/radios" | jq "$PUB_FMT_RADIO" | xargs $DLG_LIST ;} dzr_genre_0_artists() { $FETCH "$API/genre/$(_id "$1")/artists" | jq '(.data[]|("/artist/"+(.id|tostring), (.name|gsub("\\x22";""))))' | xargs $DLG_LIST ;} # radio dzr_radio() { $FETCH "$API/radio" | jq "$PUB_FMT_RADIO" | xargs $DLG_LIST ;} # public API - no gw-light equivalent dzr_radio_0() { gw_radio "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} # artist dzr_artist_0() { $DLG_LIST top 'top tracks' albums '' related '' radio '' ;} dzr_artist_0_top() { gw_artist_top "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} dzr_artist_0_albums() { gw_artist_albums "$1" | jq "$FMT_DISCOG" | xargs $DLG_LIST ;} dzr_artist_0_related() { gw_artist_related "$1" | jq "$FMT_ARTIST" | xargs $DLG_LIST ;} dzr_artist_0_radio() { gw_artist_radio "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} # album dzr_album_0() { gw_album "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} # playlist dzr_playlist_0() { gw_playlist "$1" | jq "$GW_FMT_LIST" | xargs $DLG_LIST ▸ "play all" ⇄ "shuffle" ;} play() { # receive /track/1,2,3 from stdin xargs basename | xargs $SELF-url | ${1:-cat} | while IFS= read -r line; do printf '%s\n' "$line" | grep -q ' ' || { echo "error: unexpected dzr-url output: $line" >&2; break; } url=$(printf '%s\n' "$line" | cut -f1) id=$(printf '%s\n' "$line" | cut -f2) info=$(printf '%s\n' "$line" | cut -f3-) if [ -n "$info" ]; then printf '%s %s # %s\n' "$SELF" "/track/$id" "$info" >&2 printf '\033]0;🎶 %s\007' "$(printf '%s' "$info" | sed 's/ \[.*//;s/ (.*)//')" >&2 fi $FETCH "$url" | $DZR_SPONGE | $SELF-dec $id | eval ${PLAYER:-'mpv -'} || break # stop if Ctrl+C : $? = 4 done } # play_all: call the gw fetcher for this context, extract track IDs, play play_all() { # $1=url $2=FUNC $3=order(cat|shuf) case "$2" in dzr_flow) gw_flow ;; dzr_loved) gw_loved ;; dzr_search_track) gw_search_track "$1" ;; dzr_artist_0_top) gw_artist_top "$1" ;; dzr_artist_0_radio) gw_artist_radio "$1" ;; dzr_album_0) gw_album "$1" ;; dzr_playlist_0) gw_playlist "$1" ;; dzr_radio_0) gw_radio "$1" ;; *) return 1 ;; esac | jq "$GW_FMT_TRKS" | play "$3" } for url in "$@"; do case $url in /track/*) echo $url | play; continue ;; # direct play esac FUNC=$(echo "dzr_$url" | sed "$FMT_FUNC") # path (/search/artist?q=x) to function (dzr_search_artist) type $FUNC 1>/dev/null 2>/dev/null || { echo "error: unknown path $url" >&2; continue; } case $url in # catch url that need user input (query, id) *=) url="$url$($DLG_TEXT ''|jq -rR '.|@uri')" ;; # escaped query */0) url="$url$($DLG_TEXT ''|jq -rR '.|@uri')" ;; # escaped id esac echo "$SELF $url" >&2 while path=$($FUNC $url); do # browse REPL case "$path" in ▸) play_all "$url" "$FUNC" cat; break ;; ⇄) play_all "$url" "$FUNC" shuf; break ;; ∞) while true; do gw_flow | jq "$GW_FMT_TRKS" | play cat || break; done; break ;; /*) $SELF "$path";; *) $SELF "$url/$path";; esac done done [ $# -eq 0 ] && $SELF '' # give an argument to iterate over (if none given) ================================================ FILE: dzr-dec ================================================ #!/bin/sh # NAME # dzr-dec - decode a deezer track by it ID # USAGE: # dzr-dec 321654 < enc.mp3 > dec.mp3 SNG_ID="$1" [ -z "$DZR_CBC" ] && echo "Missing 'DZR_CBC' env variable" 1>&2 && exit 1 [ -z "$SNG_ID" ] && echo "USAGE: DZR_CBC=XXXX dzr-dec 1234 < enc.mp3 > dec.mp3" 1>&2 && exit 1 dzr_cbc_hex=$(printf "$DZR_CBC" | od -An -t x1 |tr -d '\n ') track_md5_l=$(printf "%s" "$SNG_ID" | openssl md5 -r|cut -b1-16 | od -An -t x1 |tr -d '\n ') track_md5_r=$(printf "%s" "$SNG_ID" | openssl md5 -r|cut -b17-32 | od -An -t x1 |tr -d '\n ') track_key=$(for k in 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31; do # no seq in BSD a=$(printf $dzr_cbc_hex | cut -b $k-$(($k+1))) b=$(printf $track_md5_l | cut -b $k-$(($k+1))) c=$(printf $track_md5_r | cut -b $k-$(($k+1))) printf '%02x' "$((0x$a ^ 0x$b ^ 0x$c))" done) stripe_size=2048 openssl_opt="-d -nopad -bufsize $stripe_size -K $track_key -iv 0001020304050607" # OpenSSL 3 require som extra argument if openssl bf-cbc -help 2>&1 | grep -q provider; then openssl_opt="$openssl_opt -provider legacy"; fi # And now for my next loop, I'd like to return to the classics set -e; # if an iteration fail we stop the loop (you better pipe me with curl) while true; do LC_ALL=POSIX dd bs=$stripe_size count=1 status=none | openssl bf-cbc $openssl_opt 2>/dev/null 1>&4 { LC_ALL=POSIX dd bs=$stripe_size count=2 2>&3 >&4; } 3>&1 | grep -qe '^0[+]0 ' && break done 4>&1 ================================================ FILE: dzr-id3 ================================================ #!/bin/sh # USAGE Example: # ./dzr-tag 5404528 tagMe.mp3 SNG_ID=$(echo "$1" | tr -dc '0-9') # extract id from path,url,... if [ -z "$SNG_ID" -o ! -f "$2" ]; then echo "USAGE: dzr-id3 5404528 target.mp3" && exit 1 fi FETCH=${FETCH:-curl -s} # FETCH="wget -q -O -" or FETCH="curl -s -k" gw () { method="$1"; session="$2" ;apiToken="$3" ; shift 3 # curl args ... $FETCH "https://www.deezer.com/ajax/gw-light.php?method=$method&input=3&api_version=1.0&api_token=$apiToken" --header "Cookie: sid=$session" "$@" } DZR_URL="www.deezer.com/ajax/gw-light.php?method=deezer.ping&api_version=1.0&api_token" DZR_SID=$($FETCH "$DZR_URL" | jq -r .results.SESSION) USR_NFO=$(gw deezer.getUserData "$DZR_SID" "$API_TOK") USR_TOK=$(printf "%s" "$USR_NFO" | jq -r .results.USER_TOKEN) USR_LIC=$(printf "%s" "$USR_NFO" | jq -r .results.USER.OPTIONS.license_token) API_TOK=$(printf "%s" "$USR_NFO" | jq -r .results.checkForm) SNG_NFO=$(gw song.getListData "$DZR_SID" "$API_TOK" --data "{\"sng_ids\":[$SNG_ID]}") # extract ID3 field we need ART_NAME=$(echo "${SNG_NFO}" | jq -r .results.data[0].ART_NAME) REL_DATE=$(echo "${SNG_NFO}" | jq -r .results.data[0].PHYSICAL_RELEASE_DATE) # TODO: sometimes 0000-00-00 SNG_TITLE=$(echo "${SNG_NFO}" | jq -r .results.data[0].SNG_TITLE) ALB_TITLE=$(echo "${SNG_NFO}" | jq -r .results.data[0].ALB_TITLE) TRK_NUMBR=$(echo "${SNG_NFO}" | jq -r .results.data[0].TRACK_NUMBER) ALB_PICTURE=$(echo "${SNG_NFO}" | jq -r .results.data[0].ALB_PICTURE) APICNAME=".$ALB_PICTURE.jpg" $FETCH "https://e-cdn-images.dzcdn.net/images/cover/$ALB_PICTURE/1024x1024-000000-100-0-0.jpg" > "$APICNAME" FILENAME="$ART_NAME - $SNG_TITLE.mp3" # eyeD3 mid3/mutagen (both python lib) seems to be the only one supporting APIC tag # contact me if you find a dependency-less widely-available id3tag editor eyeD3 \ --artist "$ART_NAME" \ --title "$SNG_TITLE" \ --album "$ALB_TITLE" \ --track "$TRK_NUMBR" \ --release-date "$REL_DATE" \ --add-image "$APICNAME:FRONT_COVER" \ "$2" && rm "$APICNAME" && mv "$2" "$FILENAME" ================================================ FILE: dzr-srt ================================================ #!/bin/sh # USAGE Example: # ./dzr-srt 355777961 SNG_ID=$(echo "$1" | tr -dc '0-9') # extract id from path,url,... [ -z "$SNG_ID" ] && echo "USAGE: $0 5404528" && exit 1 FETCH=${FETCH:-curl -s} # FETCH="wget -q -O -" or FETCH="curl -s -k" gw () { method="$1"; session="$2" ;apiToken="$3" ; shift 3 # curl args ... $FETCH "https://www.deezer.com/ajax/gw-light.php?method=$method&input=3&api_version=1.0&api_token=$apiToken" --header "Cookie: sid=$session" "$@" } DZR_URL="www.deezer.com/ajax/gw-light.php?method=deezer.ping&api_version=1.0&api_token" DZR_SID=$($FETCH "$DZR_URL" | jq -r .results.SESSION) USR_NFO=$(gw deezer.getUserData "$DZR_SID" "$API_TOK") USR_TOK=$(printf "%s" "$USR_NFO" | jq -r .results.USER_TOKEN) USR_LIC=$(printf "%s" "$USR_NFO" | jq -r .results.USER.OPTIONS.license_token) API_TOK=$(printf "%s" "$USR_NFO" | jq -r .results.checkForm) #printf "SID=$DZR_SID\nAPI=$API_TOK\nLIC=$USR_LIC\nTOK=$USR_TOK\nIDS=$SNG_ID\n" 1>&2 gw song.getLyrics "$DZR_SID" "$API_TOK" --data "{\"sng_id\":$SNG_ID}" | jq -r 'if (.results.LYRICS_SYNC_JSON) then .results.LYRICS_SYNC_JSON | map(select(.lrc_timestamp)) | to_entries | map([.key+1, "00:"+.value.lrc_timestamp[1:-1] + .value.milliseconds, (.value.duration|tonumber/1000|tostring), .value.line])[]|@tsv else "1\t00:00:00.0\t0.0\t" end' | while IFS=' ' read -r id start length text ; do from=$(date +%H:%M:%S,%N --date "$start" | cut -c1-12) ; to=$(date +%H:%M:%S,%N --date "$start + $length second" | cut -c1-12) ; printf "$id\n${from} --> ${to}\n$text\n\n" ; done ================================================ FILE: dzr-url ================================================ #!/bin/sh # USAGE Example: # ./dzr-url 355777961 650744592 | while read url id; do curl -s "$url" | ./dzr-dec $id | mpv - ; done SNG_IDS=$(printf "%s" "$*" | sed 's/ /,/g') FETCH=${FETCH:-curl -s} # FETCH="wget -q -O -" or FETCH="curl -s -k" die() { echo "$1" >&2; exit 1; } gw () { method="$1"; session="$2" ;apiToken="$3" ; shift 3 # curl args ... $FETCH "https://www.deezer.com/ajax/gw-light.php?method=$method&input=3&api_version=1.0&api_token=$apiToken" --header "Cookie: sid=$session; arl=$DZR_ARL" "$@" } [ -z "$SNG_IDS" ] && die "USAGE: dzr-url 5404528,664107" # reuse session from parent dzr process, or create a new one if [ -n "$DZR_SID" ] && [ -n "$DZR_API_TOK" ] && [ -n "$DZR_LIC" ]; then API_TOK="$DZR_API_TOK" USR_LIC="$DZR_LIC" else DZR_SID=$($FETCH "www.deezer.com/ajax/gw-light.php?method=deezer.ping&api_version=1.0&api_token" | jq -r .results.SESSION) [ -z "$DZR_SID" ] || [ "$DZR_SID" = "null" ] && die "error: deezer.ping failed (no session)" USR_NFO=$(gw deezer.getUserData "$DZR_SID" "$API_TOK") USR_LIC=$(printf "%s" "$USR_NFO" | jq -r .results.USER.OPTIONS.license_token) API_TOK=$(printf "%s" "$USR_NFO" | jq -r .results.checkForm) [ -z "$USR_LIC" ] || [ "$USR_LIC" = "null" ] && die "error: getUserData failed (no license token)" [ -z "$API_TOK" ] || [ "$API_TOK" = "null" ] && die "error: getUserData failed (no api token)" fi SNG_NFO=$(gw song.getListData "$DZR_SID" "$API_TOK" --data "{\"sng_ids\":[$SNG_IDS]}") printf "%s" "$SNG_NFO" | jq -e '.results.data // empty' >/dev/null 2>&1 || die "error: song.getListData returned no data" SNG_TOK=$(printf "%s" "$SNG_NFO" | jq '[.results.data[]|(.FALLBACK.TRACK_TOKEN//.TRACK_TOKEN)]') # reset to resolvable id, eg:no personal uploads (geoblocked track will fail later) SNG_IDS=$(printf "%s" "$SNG_NFO" | jq '[.results.data[]|(.FALLBACK.SNG_ID//.SNG_ID)]') SNG_INF=$(printf "%s" "$SNG_NFO" | jq '[.results.data[]|"\(.ART_NAME) - \(.SNG_TITLE)\(if .VERSION != "" then " \(.VERSION)" else "" end) [\(.ALB_TITLE)] (\(.DURATION|tonumber|"\(./60|floor):\(. % 60|tostring|if length<2 then "0"+. else . end)"))"]') URL_NFO=$(printf "{\"license_token\":\"%s\",\"media\":[{\"type\":\"FULL\",\"formats\":[{\"cipher\":\"BF_CBC_STRIPE\",\"format\":\"${DZR_FMT:-MP3_128}\"}]}],\"track_tokens\":%s}" "$USR_LIC" "$SNG_TOK" | $FETCH 'https://media.deezer.com/v1/get_url' --data-binary @-) printf "%s" "$URL_NFO" | jq -e '.data // empty' >/dev/null 2>&1 || die "error: get_url returned no data" URL_IDS=$(printf "%s" "$URL_NFO" | jq --argjson ids "$SNG_IDS" --argjson inf "$SNG_INF" '[.data , ($ids|map({id:.})) , ($inf|map({info:.}))]|transpose|map(add)') printf "%s" "$URL_IDS" | jq -r '.[]|select(.errors)|[.errors[0].message ,.id]|@tsv' 1>&2 printf "%s" "$URL_IDS" | jq -r '.[]|select(.media[0].sources[0].url)|[.media[0].sources[0].url,.id,.info]|@tsv' ================================================ FILE: extension/main.js ================================================ // @ts-check /// curl -Ok https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.d.ts /// const vscode = require("vscode"); const crypto = require('crypto'); const https = require('https'); const conf = () => vscode.workspace.getConfiguration("dzr"); const location = vscode.ProgressLocation.Notification; const hhmmss = (s) => (new Date(s * 1000)).toISOString().slice(11, 19).replace(/^00:/, ''); const wait = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)); // still no fetch() in 2023 ? const fetch = (url, opt, data) => new Promise((resolve, reject) => { console.log({ url, opt, data }) const chunks = [], req = https.request(url, { rejectUnauthorized: conf().get('secure'), ...opt }, res => { res.on('data', chunk => chunks.push(chunk)); res.on('end', () => resolve(Buffer.concat(chunks))); }).on('error', reject); if (data) req.write(data); req.end(); }); // deezer API wall of shame: // - not restful, so we can't infer it structure // - /track/:id gives contributors but /search/track?q= don't // - inconsistent listing structure (/playlist/:id => tracks.data, sometimes=>data, sometimes data.tracks) // browse can be called from: user query / self list(from static menu) / self list(from fetch result) async function browse(url_or_event_or_ids, label) { try { if (Array.isArray(url_or_event_or_ids)) return url_or_event_or_ids.map(id => ({ id })); const ignoreFocusOut = true; const url = typeof (url_or_event_or_ids) == "string" ? url_or_event_or_ids : '/'; const id = url.replace(/\d+/g, '0').replace(/[^\w]/g, '_'); const menus = conf().get('menus'); const title = (label || '').replace(/\$\(.+?\)/g, ''); if (url.endsWith('=') || url.endsWith('/0')) { // query step const input = await vscode.window.showInputBox({ title, ignoreFocusOut }); if (!input) return; return await browse(url.replace(/0$/, '') + input, `${label}: ${input}`); } else if (menus[id]) { // menu step const pick = menus[id].length > 1 ? await vscode.window.showQuickPick(menus[id], { title: title || url, ignoreFocusOut }) : menus[id][0]; if (!pick) return; return await browse(url + pick.path, pick.label); } else { // fetch step /**@type {{type:string,title_short:string,artist:{name:string},title_version:string,title:string,name:string,nb_tracks:string,id:number}[]} */ const data = []; for (let n = conf().get('nextCount'), json, nextUrl = "https://api.deezer.com" + url; --n && nextUrl && (json = JSON.parse(await fetch(nextUrl))); nextUrl = json.next) { data.push(...(json.data?.tracks || json.data || json.tracks?.data)); } const picked = !!url.match(/\/(playlist|album)\//); const canPickMany = !!data.find(item => item.type == "track"); const type2icon = conf().get('type2icon'); const choices = data.map(entry => ({ ...entry, picked, label: (type2icon[entry.type] || '') + (entry.title_short || entry.name || entry.title), description: [entry.artist?.name, entry.title_version, entry.nb_tracks].join(' '), path: `/${entry.type}/${entry.id}`, })); const picks = await vscode.window.showQuickPick(choices, { title: title || url, canPickMany, ignoreFocusOut }); if (!picks) return; return canPickMany ? picks : await browse(picks.path, picks.label); } } catch (e) { console.error(e) } } let DZR_PNG, USR_NFO; const with_url = async (songs) => songs?.length ? await vscode.window.withProgress({ title: 'Fetching', location }, async (progress) => { try { // take 7s (with, or without agent) const next = (message, val) => (progress.report({ increment: 100 / 4, message }), val); const gw = async (method, arl, sid, api_token = "", opt = {}, data) => JSON.parse(await fetch(`${base}&method=${method}&api_token=${api_token}`, { ...opt, headers: { Cookie: `sid=${sid}; arl=${arl}`, ...opt?.headers } }, data)).results; const base = "https://www.deezer.com/ajax/gw-light.php?input=3&api_version=1.0"; let DZR_ARL = next("ARL", conf().get('arl')); const format = conf().get('format') || 'MP3_128'; if (!DZR_ARL) { DZR_ARL = (await vscode.window.showInputBox({ ignoreFocusOut: true, placeHolder: "deezer://autolog/xxxx", prompt: "Login on [deezer.com](https://www.deezer.com/), then copy the button address displayed on [this page](https://www.deezer.com/desktop/login/electron/callback)" }))?.match(/[0-9a-f]{192}/)?.[0]; if (!DZR_ARL) return vscode.window.showErrorMessage("No ARL found"); conf().update('arl', DZR_ARL, vscode.ConfigurationTarget.Global); } DZR_PNG = next("session", DZR_PNG || await gw('deezer.ping', DZR_ARL)); USR_NFO = next("user right", USR_NFO || await gw('deezer.getUserData', DZR_ARL, DZR_PNG.SESSION)); const SNG_NFO = next("song info", await gw('song.getListData', DZR_ARL, DZR_PNG.SESSION, USR_NFO.checkForm, { method: 'POST' }, JSON.stringify({ sng_ids: songs.map(s => s.id) }))).data.map(e => e.FALLBACK || e); const GET_URL = next("song stream", JSON.parse(await fetch('https://media.deezer.com/v1/get_url', { method: 'POST' }, JSON.stringify({ track_tokens: SNG_NFO.map(d => d.TRACK_TOKEN), license_token: USR_NFO.USER.OPTIONS.license_token, media: [{ type: "FULL", formats: [{ cipher: "BF_CBC_STRIPE", format }] }] }))))?.data?.map((url, i) => Object.assign(SNG_NFO[i], url)); const errors = SNG_NFO.filter(e => e.errors).map(e => `[${e.SNG_ID}] ${e.ART_NAME} - ${e.SNG_TITLE}:${JSON.stringify(e.errors)}`); const skiped = SNG_NFO.filter(s => !s.media?.[0]?.sources?.[0]?.url).map(e => `[${e.SNG_ID}] no MEDIA`); if (errors.length || skiped.length) { vscode.window.showWarningMessage([...errors, ...skiped].join('\n'), "Continue", "Flush ARL") .then(e => { if (e == "Flush ARL") conf().update('arl', undefined, vscode.ConfigurationTarget.Global) }); } return SNG_NFO.map(nfo => ({ id: nfo.SNG_ID, md5_image: nfo.ALB_PICTURE, duration: +nfo.DURATION, title: nfo.SNG_TITLE.replace(/ ?\(feat.*?\)/, ''), artists: (nfo.ARTISTS || []).map(a => ({ id: a.ART_ID, name: a.ART_NAME, md5: a.ART_PICTURE })), size: +nfo.FILESIZE, expire: nfo.TRACK_TOKEN_EXPIRE, url: nfo.media?.[0]?.sources?.[0]?.url })).filter(nfo => nfo.url); } catch (e) { console.error(e); vscode.window.showErrorMessage(e); return [] } }) : []; class DzrWebView { // can't Audio() in VSCode, we need a webview statuses = ['dzr.play', 'dzr.show', 'dzr.load'].map((command) => { const item = vscode.window.createStatusBarItem(command, vscode.StatusBarAlignment.Left, 10000); item.color = new vscode.ThemeColor('statusBarItem.prominentBackground'); item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); item.command = command; item.text = command; item.show(); return item; }); /**@type {vscode.WebviewPanel|null}*/ panel = null; #state = {}; state = new Proxy(this.#state, { set: (target, k, value) => { const key = String(k); target[key] = value; if (['queue', 'looping'].includes(key)) { // persist those values across reboot conf().update(key, value, vscode.ConfigurationTarget.Global); } if (key == 'queue') this._onDidChangeTreeData.fire(null); vscode.commands.executeCommand('setContext', `dzr.${key}`, value); this.post('state', target, [key]); this.renderStatus(); return true; } }); constructor() { this.initAckSemaphore(); this.state.queue = conf().get('queue'); // first is best this.state.looping = conf().get('looping'); } renderStatus() { const index = this.state.queue?.indexOf(this.state.current); const label = this.state.current ? `${this.state.current.title} - ${this.state.current.artists?.map(a => a.name).join()}` : ''; this.statuses[0].command = this.state.playing ? 'dzr.pause' : 'dzr.play'; this.statuses[0].text = this.state.ready && (this.state.playing ? "$(debug-pause)" : "$(play)"); this.statuses[1].tooltip = this.state.ready ? label : "Initiate interaction first"; this.statuses[1].text = this.state.ready ? label.length < 20 ? label : (label.slice(0, 20) + '…') : "$(play)" this.statuses[2].text = this.state.ready && this.state.queue.length ? `${index + 1 || '?'}/${this.state.queue.length} $(chevron-right)` : '';//debug-step-over this.treeView.title = (this.state.queue?.length ? `${index + 1 || '?'}/${this.state.queue.length}` : '') + ` loop:${this.state.looping}`; this.treeView.message = this.state.queue?.length ? "" : "Empty Queue. Add tracks from the '+' menu"; this.treeView.badge = { tooltip: label, value: this.state.playing ? index + 1 : 0 } } async show(htmlUri, iconPath) { if (this.panel) return this.panel.reveal(vscode.ViewColumn.One); this.panel = vscode.window.createWebviewPanel('dzr.player', 'Player', vscode.ViewColumn.One, { enableScripts: true, enableCommandUris: true, retainContextWhenHidden: true, }); this.panel.iconPath = iconPath; this.panel.webview.html = (await vscode.workspace.fs.readFile(htmlUri)).toString(); this.panel.webview.onDidReceiveMessage(([action, ...args] = []) => this[action] ? this[action](...args) : this.badAction(action)); this.panel.onDidDispose(() => this.state.ready = this.panel = null); this.post('state', this.state, Object.keys(this.state)); } initAckSemaphore() { this.postAck = new Promise((then) => this.waitAckSemaphore = then); } post = (action, ...arg) => this.panel?.webview.postMessage([action, ...arg]); // event from webview player player_bufferized() { this.waitAckSemaphore?.(0); this.initAckSemaphore(); } player_volumechange({ volume }) { conf().update("volume", volume, vscode.ConfigurationTarget.Global); } player_playing() { this.state.ready = this.state.playing = true; } player_pause() { this.state.playing = false; } player_ended() { vscode.commands.executeCommand('dzr.load', null); } user_interact() { this.state.ready = true; } user_next() { vscode.commands.executeCommand('dzr.load'); } error(msg) { vscode.window.showErrorMessage(msg); } badAction(action) { console.error(`unHandled action "${action}" from webview`); } // tree dropMimeTypes = ['application/vnd.code.tree.dzrQueue']; dragMimeTypes = ['text/uri-list']; _onDidChangeTreeData = new vscode.EventEmitter(); onDidChangeTreeData = this._onDidChangeTreeData.event; /**@type {import('vscode').TreeView}*/ treeView = vscode.window.createTreeView('dzr.queue', { treeDataProvider: this, dragAndDropController: this, canSelectMany: true }); highlighted = (label, active) => ({ label, highlights:/**@type {[number, number][]}*/(active ? [[0, label.length]] : []) }) /**@returns {vscode.TreeItem} */ getTreeItem = (item) => ({ iconPath: new vscode.ThemeIcon("music"), label: this.highlighted(item.title + ' - ' + item.artists.map(a => a.name).join(), item == this.state.current), description: hhmmss(item.duration || 0) + " " + (item.version || ''), contextValue: 'dzr.track', command: { title: 'Play', command: 'dzr.load', tooltip: 'Play', arguments: [this.state.queue.indexOf(item)] }, }) getParent = () => null getChildren = () => { return this.state.queue } async handleDrag(sources, treeDataTransfer) { treeDataTransfer.set(this.dropMimeTypes[0], new vscode.DataTransferItem(sources)); } async handleDrop(onto, transfer) { const sources = transfer.get(this.dropMimeTypes[0])?.value; if (!sources || sources.includes(onto)) return; //don't move selection onto one of it members const striped = this.state.queue.filter(item => !sources.includes(item)); const index = this.state.queue.indexOf(onto); this.state.queue = [...striped.slice(0, index), ...sources, ...striped.slice(index)]; } } exports.activate = async function (/**@type {import('vscode').ExtensionContext}*/ context) { // deezer didn't DMCA'd dzr so let's follow the same path here conf().get('cbc') || vscode.window.withProgress({ title: 'Extracting CBC key...', location }, async () => { const html_url = 'https://www.deezer.com/en/channels/explore'; const html = (await fetch(html_url)).toString('utf-8'); const js_url = html.match(/src="(http[^"]+app-web\.[^"]+\.js)"/)?.[1]; if (!js_url) return await vscode.window.showErrorMessage('CBC Extract: No JS WebApp found'); const keys = (await fetch(js_url)).toString('utf-8').match(/%5B0x..%2C.{39}%2C0x..%5D/g); const [a, b] = keys.map(part => part.slice(3, -3).split('%2C').map(i => String.fromCharCode(parseInt(i))).reverse()); const cbc = a.map((a, i) => `${a}${b[i]}`).join('');// zip a+b const sha = crypto.createHash('sha1').update(cbc).digest('hex').slice(0, 8); if (sha != '3ad58d92') return await vscode.window.showErrorMessage('Bad extracted key'); conf().update('cbc', cbc, vscode.ConfigurationTarget.Global); }); const dzr = new DzrWebView(); const htmlUri = vscode.Uri.joinPath(context.extensionUri, 'webview.html'); const iconUri = vscode.Uri.joinPath(context.extensionUri, 'logo.svg'); //same for light+dark context.subscriptions.push(...dzr.statuses, dzr.treeView, // catch vscode://yne.dzr/* urls vscode.window.registerUriHandler({ handleUri(uri) { (({ path, query }) => vscode.commands.executeCommand(`dzr.${path.slice(1)}`, ...(query ? JSON.parse(query) : [])))(uri); } }), vscode.commands.registerCommand('dzr.show', () => dzr.show(htmlUri, iconUri)), vscode.commands.registerCommand("dzr.play", () => dzr.post('play')), vscode.commands.registerCommand("dzr.pause", () => dzr.post('pause')), vscode.commands.registerCommand("dzr.href", (track) => vscode.env.openExternal(vscode.Uri.parse(`https://deezer.com/track/${track.id}`))), vscode.commands.registerCommand("dzr.loopQueue", () => dzr.state.looping = "queue"), vscode.commands.registerCommand("dzr.loopTrack", () => dzr.state.looping = "track"), vscode.commands.registerCommand("dzr.loopOff", () => dzr.state.looping = "off"), vscode.commands.registerCommand("dzr.add", async (path, label) => with_url(await browse(path, label)).then(tracks => dzr.state.queue = [...dzr.state.queue, ...tracks])), vscode.commands.registerCommand("dzr.remove", async (item, items) => (items || [item]).map(i => vscode.commands.executeCommand('dzr.removeAt', dzr.state.queue.indexOf(i)))), vscode.commands.registerCommand("dzr.removeAt", async (index) => index >= 0 && (dzr.state.queue = [...dzr.state.queue.slice(0, index), ...dzr.state.queue.slice(index + 1)])), vscode.commands.registerCommand("dzr.clear", async () => dzr.state.queue = []), vscode.commands.registerCommand("dzr.shareAll", async () => vscode.commands.executeCommand("dzr.share")), vscode.commands.registerCommand("dzr.share", async (track, tracks) => { const ids = JSON.stringify(track ? [(tracks || [track]).map(e => e.id || track.id)] : [dzr.state.queue.map(q => q.id)]); vscode.env.clipboard.writeText(vscode.Uri.from({ scheme: "vscode", authority: context.extension.id, path: '/add', query: ids }).toString()) }), vscode.commands.registerCommand("dzr.shuffle", async () => { const shuffle = [...dzr.state.queue]; for (let i = shuffle.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffle[i], shuffle[j]] = [shuffle[j], shuffle[i]]; } dzr.state.queue = shuffle; }), vscode.commands.registerCommand("dzr.load", async (pos) => { //pos=null if player_end / pos=undefine if user click pos = pos ?? dzr.state.queue.indexOf(dzr.state.current) + (dzr.state.looping == 'track' ? 0 : 1); if (!dzr.state.queue[pos]) { // out of bound track if (dzr.state.looping == 'off') return; // don't loop if unwanted pos = 0; // loop position if looping } if (!dzr.state.ready) { vscode.commands.executeCommand('dzr.show'); while (!dzr.state.ready) await wait(); } if ((dzr.state.queue[pos].expire || 0) < (+new Date() / 1000)) { dzr.state.queue = await with_url(dzr.state.queue);//TODO: hope item is now up to date } const prev = dzr.state?.current; dzr.state.current = dzr.state.queue[pos]; dzr._onDidChangeTreeData.fire(prev); dzr._onDidChangeTreeData.fire(this.state?.current); const hex = (str) => str.split('').map(c => c.charCodeAt(0)) const md5 = hex(crypto.createHash('md5').update(`${dzr.state.current.id}`).digest('hex')); const key = Buffer.from(hex(conf().get('cbc')).map((c, i) => c ^ md5[i] ^ md5[i + 16])); const iv = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]); const stripe = 2048;//TODO:use .pipe() API https://codereview.stackexchange.com/questions/57492/ dzr.post('open', dzr.state.current, conf().get("volume")); const buf_enc = await fetch(dzr.state.current.url); for (let pos = 0; pos < buf_enc.length; pos += stripe) { if ((pos >> 11) % 3) continue; const ciph = crypto.createDecipheriv('bf-cbc', key, iv).setAutoPadding(false) const deco = ciph.update(buf_enc.subarray(pos, pos + stripe)); buf_enc.set(deco, pos); } dzr.post('append', Uint8Array.from(buf_enc)); await dzr.postAck; dzr.post('close'); }), ) } ================================================ FILE: extension/package.json ================================================ { "name": "dzr", "displayName": "DZR player", "description": "deezer.com player", "version": "0.3.0", "publisher": "yne", "engines": { "vscode": "^1.73.0" }, "main": "./main.js", "extensionKind": [ "ui", "workspace" ], "activationEvents": [ "onStartupFinished" ], "capabilities": { "untrustedWorkspaces": { "supported": true } }, "contributes": { "viewsWelcome": [ { "view": "explorer.dzr", "contents": "not shown" } ], "keybindings (displayed but dont work)": [ { "command": "dzr.remove", "key": "delete" } ], "commands": [ { "category": "dzr", "command": "dzr.show", "title": "Show Player Tab", "icon": "$(eye)" }, { "category": "dzr", "command": "dzr.play", "title": "Play", "icon": "$(debug-run)" }, { "category": "dzr", "command": "dzr.href", "title": "Open Web", "when": "false", "icon": "$(link-external)" }, { "category": "dzr", "command": "dzr.pause", "title": "Pause", "icon": "$(debug-pause)" }, { "category": "dzr", "command": "dzr.load", "title": "Play next", "icon": "$(chevron-right)" }, { "category": "dzr", "command": "dzr.loopQueue", "title": "Loop Queue", "icon": "$(sync)" }, { "category": "dzr", "command": "dzr.loopTrack", "title": "Loop Track", "icon": "$(redo)" }, { "category": "dzr", "command": "dzr.loopOff", "title": "Loop Disabled", "icon": "$(sync-ignored)" }, { "category": "dzr", "command": "dzr.add", "title": "Queue Add", "icon": "$(add)" }, { "category": "dzr", "command": "dzr.remove", "title": "Queue Remove Track", "when": "false", "icon": "$(close)" }, { "category": "dzr", "command": "dzr.clear", "title": "Queue Remove All", "icon": "$(clear-all)" }, { "category": "dzr", "command": "dzr.shuffle", "title": "Queue Shuffle", "icon": "$(arrow-swap)" }, { "category": "dzr", "command": "dzr.share", "title": "Copy vscode:// Link(s)", "icon": "$(link)" }, { "category": "dzr", "command": "dzr.shareAll", "title": "Copy all vscode:// Links", "icon": "$(link)" } ], "menus": { "view/title": [ { "when": "view == dzr.queue", "group": "navigation@9", "command": "dzr.add" }, { "when": "view == dzr.queue && dzr.queue!=''", "group": "navigation@8", "command": "dzr.clear" }, { "when": "view == dzr.queue && dzr.queue!=''", "group": "navigation@7", "command": "dzr.shareAll" }, { "when": "view == dzr.queue && dzr.queue!=''", "group": "navigation@6", "command": "dzr.shuffle" }, { "when": "view == dzr.queue && dzr.queue!='' && dzr.looping == 'off'", "group": "navigation@2", "command": "dzr.loopQueue" }, { "when": "view == dzr.queue && dzr.queue!='' && dzr.looping == 'queue'", "group": "navigation@2", "command": "dzr.loopTrack" }, { "when": "view == dzr.queue && dzr.queue!='' && dzr.looping == 'track'", "group": "navigation@2", "command": "dzr.loopOff" } ], "view/item/context": [ { "group": "inline", "command": "dzr.remove", "when": "viewItem == dzr.track && !listMultiSelection" }, { "group": "navigation", "command": "dzr.remove", "when": "viewItem == dzr.track && listMultiSelection" }, { "group": "navigation", "command": "dzr.href", "when": "viewItem == dzr.track && !listMultiSelection" }, { "group": "navigation", "command": "dzr.share", "when": "viewItem == dzr.track" } ] }, "configuration": { "type": "object", "title": "dzr configuration", "properties": { "dzr.cbc": { "type": "string", "default": "", "description": "track decryption key" }, "dzr.arl": { "type": "string", "default": "", "description": "Access Rights Locator" }, "dzr.format": { "type": "string", "default": "MP3_128", "enum": [ "AAC_64", "FLAC", "MP3_64", "MP3_128", "MP3_256", "MP3_320", "MP4_RA1", "MP4_RA2", "MP4_RA3" ], "description": "Requested format" }, "dzr.nextCount": { "type": "number", "default": 5, "markdownDescription": "Number of `.next` page to fetch before showing quickpick (`-1 for unlimited`)" }, "dzr.secure": { "type": "boolean", "default": false, "description": "Disable if you are behind a corporate proxy" }, "dzr.volume": { "type": "number", "default": 1, "minimum": 0, "maximum": 1, "description": "Player volume [0..1], Applied at next track play" }, "dzr.queue": { "type": "array", "default": [], "description": "Persistent track queue" }, "dzr.looping": { "type": "string", "enum": [ "queue", "track", "off" ], "default": "queue", "description": "Queue looping" }, "dzr.menus": { "type": "object", "description": "API tree (since deezer endpoint are not RESTful)", "default": { "_": [ { "path": "search/track?q=", "label": "$(play-circle) track search" }, { "path": "search/artist?q=", "label": "$(person) artist search" }, { "path": "search/album?q=", "label": "$(issues) album search" }, { "path": "search/playlist?q=", "label": "$(list-unordered) playlist search" }, { "path": "search/user?q=", "label": "$(account) user search" }, { "path": "search/radio?q=", "label": "$(broadcast) radio search" }, { "path": "genre", "label": "$(telescope) explore" }, { "path": "radio", "label": "$(broadcast) radios list" }, { "path": "user/0", "label": "$(account) user id" } ], "_artist_0": [ { "path": "/top?limit=50", "label": "$(star) Top Tracks" }, { "path": "/albums", "label": "$(issues) Albums" }, { "path": "/related", "label": "$(person) Similar Artists" }, { "path": "/radio", "label": "$(broadcast) Flow" }, { "path": "/playlists", "label": "$(list-unordered) Playlists" } ], "_user_0": [ { "path": "/playlists", "label": "$(list-unordered) Playlists" }, { "path": "/tracks", "label": "$(play-circle) Favorite Tracks" }, { "path": "/albums", "label": "$(issues) Favorite Albums" }, { "path": "/artists", "label": "$(person) Favorite Artists" }, { "path": "/flow", "label": "$(broadcast) Flow" }, { "path": "/charts", "label": "$(play-circle) Charts" } ], "_genre_0": [ { "path": "/radios", "label": "$(broadcast) radios" }, { "path": "/artists", "label": "$(person) Artists" } ], "_radio_0": [ { "path": "/tracks", "label": "$(list-unordered) tracks" } ], "_album_0": [ { "path": "/tracks", "label": "$(list-unordered) tracks" } ] } }, "dzr.type2icon": { "type": "object", "description": "VSCode picker icon to display according to item type", "additionalProperties": true, "default": { "track": "$(play-circle) ", "artist": "$(person) ", "album": "$(issues) ", "playlist": "$(list-unordered)", "radio": "$(broadcast) ", "genre": "$(telescope) ", "user": "$(account) " }, "patternProperties": { ".*": { "type": "string" } } } } }, "viewsContainers": { "activitybar": [ { "id": "dzr", "title": "dzr", "icon": "logo.svg" } ] }, "views": { "dzr": [ { "id": "dzr.queue", "name": "Player Queue", "icon": "logo.svg" } ] } } } ================================================ FILE: extension/webview.html ================================================

Disclamer

  • Use the Player Queue side-panel to add/remove track
  • Closing this player tab will stop the playback
================================================ FILE: index.html ================================================ Warning: dzr don't normaly use https, please downgrade to http://
Options