├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── src └── ya.pl └── usage.gif /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | workflow_dispatch: # Manual only 5 | 6 | jobs: 7 | 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - 14 | name: Checkout 15 | uses: actions/checkout@v3 16 | - 17 | name: Docker Setup Buildx 18 | uses: docker/setup-buildx-action@v2.7.0 19 | - 20 | name: Login to Docker Hub 21 | uses: docker/login-action@v3 22 | with: 23 | username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | password: ${{ secrets.DOCKERHUB_TOKEN }} 25 | - 26 | name: Build and push 27 | uses: docker/build-push-action@v4 28 | with: 29 | push: true 30 | platforms: linux/arm/v7,linux/arm/v6,linux/arm64/v8,linux/amd64,linux/386 31 | tags: ka1mi/yandex-music-downloader:latest,ka1mi/yandex-music-downloader:${{github.ref_name}} 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Swap 2 | [._]*.s[a-v][a-z] 3 | [._]*.sw[a-p] 4 | [._]s[a-rt-v][a-z] 5 | [._]ss[a-gi-z] 6 | [._]sw[a-p] 7 | 8 | # nix build 9 | result 10 | 11 | # Session 12 | Session.vim 13 | Sessionx.vim 14 | 15 | # Temporary 16 | .netrwhist 17 | *~ 18 | # Auto-generated tag files 19 | tags 20 | # Persistent undo 21 | [._]*.un~ 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | ENV LANG=en_US.UTF-8 LC_ALL=C.UTF-8 LANGUAGE=en_US.UTF-8 3 | RUN apk --update add perl perl-app-cpanminus make unzip 4 | RUN apk add perl-libwww perl-lwp-protocol-https perl-http-cookies perl-html-parser perl-getopt-long-descriptive perl-archive-zip \ 5 | --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ 6 | RUN ["cpanm", "MP3::Tag", "File::Util"] 7 | COPY src /src 8 | ENTRYPOINT [ "/src/ya.pl" ] 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 Kaimi, https://kaimi.io 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Yandex Music Downloader 2 | ===================== 3 | 4 | [![Telegram](https://img.shields.io/badge/Telegram--lightgrey?logo=telegram&style=social)](https://t.me/kaimi_io) 5 | [![Twitter](https://img.shields.io/twitter/follow/kaimi_io?style=social)](https://twitter.com/kaimi_io) 6 | 7 | ![Yandex Music Downloader usage](https://github.com/kaimi-io/yandex-music-download/blob/master/usage.gif?raw=true) 8 | 9 | Simple command line Perl script for downloading music from Yandex Music (http://music.yandex.ru). 10 | Origin of the script is the following article: https://kaimi.io/2013/11/yandex-music-downloader/. 11 | 12 | ## Requirements 13 | ### Environment 14 | * Linux/Windows/MacOS (anything, that runs Perl) 15 | * Perl >= 5.12 16 | 17 | ### Perl modules 18 | * General 19 | * Digest::MD5 20 | * File::Copy 21 | * File::Spec 22 | * File::Temp 23 | * [File::Util](https://github.com/tommybutler/file-util) 24 | * Getopt::Long::Descriptive 25 | * HTML::Entities 26 | * HTTP::Cookies 27 | * JSON::PP 28 | * LWP::Protocol::https 29 | * LWP::UserAgent 30 | * MP3::Tag 31 | * Term::ANSIColor 32 | * Mozilla::CA 33 | 34 | * Windows-only modules 35 | * Win32::API 36 | * Win32::Console 37 | * Win32API::File 38 | 39 | ## Installation 40 | ### Ubuntu / Debian 41 | ```bash 42 | # Prerequisites 43 | sudo apt-get update 44 | sudo apt-get -y install perl cpanminus make git 45 | sudo apt-get -y install libwww-perl liblwp-protocol-https-perl libhttp-cookies-perl libhtml-parser-perl libmp3-tag-perl libgetopt-long-descriptive-perl libarchive-zip-perl 46 | cpanm Mozilla::CA 47 | 48 | # Get a copy and run 49 | git clone https://github.com/kaimi-io/yandex-music-download.git 50 | cd yandex-music-download/src 51 | perl ya.pl -h 52 | ``` 53 | ### Nix / NixOS 54 | ```bash 55 | nix shell github:kaimi-io/yandex-music-download 56 | ya-music -h 57 | ``` 58 | ### MacOS 59 | 1. Install brew (https://brew.sh/). 60 | 2. Run: 61 | ```bash 62 | brew update 63 | brew install perl cpanminus git 64 | cpanm Digest::MD5 File::Copy File::Spec File::Temp File::Util Getopt::Long::Descriptive HTML::Entities HTTP::Cookies JSON::PP LWP::Protocol::https LWP::UserAgent MP3::Tag Term::ANSIColor Mozilla::CA 65 | 66 | git clone https://github.com/kaimi-io/yandex-music-download.git 67 | cd yandex-music-download/src 68 | perl ya.pl -h 69 | ``` 70 | ### Windows 71 | With WSL (Windows Subsystem for Linux) installation will be similar to [Ubuntu / Debian](#ubuntu--debian). 72 | Otherwise: 73 | 1. Download and install ActiveState Perl (https://www.activestate.com/products/perl/downloads/) or Strawberry Perl (http://strawberryperl.com/). 74 | 2. Ensure, that Perl was added to system `PATH` environment variable. 75 | 3. From Windows command line run: 76 | ```perl -v```. It should output Perl version. If not, refer to your Perl distribution documentation about adding Perl to your `PATH` environment variable. 77 | 78 | 4. Install required modules (it can be done via PPM if you're using ActiveState Perl): 79 | ```bash 80 | cpan install Digest::MD5 File::Copy File::Spec File::Temp File::Util Getopt::Long::Descriptive HTML::Entities HTTP::Cookies JSON::PP LWP::Protocol::https LWP::UserAgent MP3::Tag Term::ANSIColor Mozilla::CA Win32::API Win32::Console Win32API::File 81 | ``` 82 | 5. Download and unpack Yandex Music Downloader (https://github.com/kaimi-io/yandex-music-download/archive/master.zip). 83 | 6. Run: 84 | ```bash 85 | cd yandex-music-download/src 86 | perl ya.pl -h 87 | ``` 88 | 89 | ### Docker 90 | 1. Install Docker (https://docs.docker.com/get-docker/). 91 | 2. Pull image from Docker Hub (https://hub.docker.com/r/ka1mi/yandex-music-downloader): 92 | ```bash 93 | docker pull ka1mi/yandex-music-downloader:latest 94 | ``` 95 | 3. Or build it: 96 | ```bash 97 | git clone https://github.com/kaimi-io/yandex-music-download.git 98 | cd yandex-music-download 99 | docker build --tag yandex-music-downloader:1.0 . 100 | ``` 101 | 4. Run: 102 | ```bash 103 | docker run --init --rm -v ${PWD}:/root/ --name yamusic yandex-music-downloader:1.0 -d /root --cookie "Session_id=..." -u https://music.yandex.ru/album/215688/track/1710808 104 | ``` 105 | 106 | ## Usage 107 | ```bat 108 | Yandex Music Downloader v1.5 109 | 110 | ya.pl [-adhklpstu] [long options...] 111 | -p[=INT] --playlist[=INT] playlist id to download 112 | -k[=STR] --kind[=STR] playlist kind (eg. ya-playlist, 113 | music-blog, music-partners, etc.) 114 | -a[=INT] --album[=INT] album to download 115 | -t[=INT] --track[=INT] track to download (album id must be 116 | specified) 117 | -u[=STR] --url[=STR] download by URL 118 | -d[=STR] --dir[=STR] download path (current direcotry will be 119 | used by default) 120 | --skip-existing skip downloading tracks that already exist 121 | on the specified path 122 | --proxy STR HTTP-proxy (format: 1.2.3.4:8888) 123 | --exclude STR skip tracks specified in file 124 | --include STR download only tracks specified in file 125 | --delay INT delay between downloads (in seconds) 126 | --mobile INT use mobile API 127 | --auth STR authorization header for mobile version 128 | (OAuth...) 129 | --cookie STR authorization cookie for web version 130 | (Session_id=...) 131 | --bitrate INT bitrate (eg. 64, 128, 192, 320) 132 | --pattern STR track naming pattern 133 | --path STR path saving pattern 134 | 135 | Available placeholders: #number, #artist, 136 | #title, #album, #year 137 | 138 | Path pattern will be used in addition to 139 | the download path directory 140 | 141 | Example path pattern: #artist/#album-#year 142 | 143 | -l --link do not fetch, only print links to the 144 | tracks 145 | -s --silent do not print informational messages 146 | --debug print debug info during work 147 | -h --help print usage 148 | 149 | --include and --exclude options use weak 150 | match i.e. ~/$term/ 151 | 152 | Example: 153 | ya.pl -p 123 -k ya-playlist 154 | ya.pl -a 123 155 | ya.pl -a 123 -t 321 156 | ya.pl -u 157 | https://music.yandex.ru/album/215690 158 | --cookie ... 159 | ya.pl -u 160 | https://music.yandex.ru/album/215688/track/1710808 --auth ... 161 | ya.pl -u 162 | https://music.yandex.ru/users/ya.playlist/playlists/1257 --cookie ... 163 | 164 | © 2013-2023 by Kaimi (https://kaimi.io) 165 | ``` 166 | 167 | ## FAQ 168 | ### What is the cause for "[ERROR] Yandex.Music is not available"? 169 | Currently Yandex Music is available only for Russia and CIS countries. For other countries you should either acquire paid subscription or use it through proxy (```--proxy``` parameter) from one of those countries. Thus it is possible to download from any country if you have an active Yandex.Music service subscription (https://music.yandex.ru/pay). 170 | 171 | ## Contribute 172 | If you want to help make Yandex Music Downloader better the easiest thing you can do is to report issues and feature requests. Or you can help in development. 173 | 174 | ## License 175 | Yandex Music Downloader Copyright © 2013-2022 by Kaimi (Sergey Belov) - https://kaimi.io. 176 | 177 | Yandex Music Downloader is free software: you can redistribute it and/or modify it under the terms of the Massachusetts Institute of Technology (MIT) License. 178 | 179 | You should have received a copy of the MIT License along with Yandex Music Downloader. If not, see [MIT License](LICENSE). 180 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1700650476, 24 | "narHash": "sha256-V269pVJbPgXDdwCA6f2LRcIsnKg9K/RPE5gyxKkxty4=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "cb502b4d17fb2912444b0b6e9813f71ba2ebbfd9", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "root": { 37 | "inputs": { 38 | "flake-utils": "flake-utils", 39 | "nixpkgs": "nixpkgs" 40 | } 41 | }, 42 | "systems": { 43 | "locked": { 44 | "lastModified": 1681028828, 45 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 46 | "owner": "nix-systems", 47 | "repo": "default", 48 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "nix-systems", 53 | "repo": "default", 54 | "type": "github" 55 | } 56 | } 57 | }, 58 | "root": "root", 59 | "version": 7 60 | } 61 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = {self, nixpkgs, flake-utils}: flake-utils.lib.eachDefaultSystem (system: 8 | let 9 | pkgs = import nixpkgs { inherit system; }; 10 | yaMusic = pkgs.stdenv.mkDerivation { 11 | name = "yandex-download-music"; 12 | version = "v1.5"; 13 | src = ./.; 14 | 15 | nativeBuildInputs = [ 16 | pkgs.makeWrapper 17 | ]; 18 | 19 | buildInputs = [ 20 | pkgs.perl 21 | (pkgs.buildEnv { 22 | name = "rt-perl-deps"; 23 | paths = with pkgs.perlPackages; (requiredPerlModules [ 24 | FileUtil 25 | MP3Tag 26 | GetoptLongDescriptive LWPUserAgent 27 | LWPProtocolHttps 28 | HTTPCookies 29 | MozillaCA 30 | ]); 31 | }) 32 | ]; 33 | 34 | installPhase = '' 35 | mkdir -p $out/bin 36 | cp src/ya.pl $out/bin/ya-music 37 | # cat src/ya.pl | perl -p -e "s/basename\(__FILE__\)/'ya-music'/g" > $out/bin/ya-music 38 | # chmod +x $out/bin/ya-music 39 | ''; 40 | 41 | postFixup = '' 42 | # wrapProgram will rename ya-music into .ya-music-wrapped 43 | # so replace all __FILE__ calls 44 | substituteInPlace $out/bin/ya-music \ 45 | --replace "basename(__FILE__)" "'ya-music'" 46 | 47 | wrapProgram $out/bin/ya-music \ 48 | --prefix PERL5LIB : $PERL5LIB 49 | ''; 50 | }; 51 | in 52 | { 53 | packages.default = yaMusic; 54 | apps.default = flake-utils.lib.mkApp { drv = yaMusic; }; 55 | } 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/ya.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use utf8; 4 | use strict; 5 | use warnings; 6 | use Encode qw/from_to decode/; 7 | use Encode::Guess; 8 | use File::Basename; 9 | use POSIX qw/strftime/; 10 | 11 | use constant IS_WIN => $^O eq 'MSWin32'; 12 | use constant 13 | { 14 | NL => IS_WIN ? "\015\012" : "\012", 15 | TIMEOUT => 10, 16 | AGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', 17 | MOBILE_AGENT => 'Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Mobile Safari/537.36', 18 | YANDEX_BASE => 'https://music.yandex.ru', 19 | MOBILE_YANDEX_BASE => 'https://api.music.yandex.net', 20 | MD5_SALT => 'XGRlBW9FXlekgbPrRHuSiA', 21 | DOWNLOAD_INFO_MASK => '/api/v2.1/handlers/track/%d:%d/web-album_track-track-track-main/download/m?external-domain=music.yandex.ru&overembed=no&__t=%d&hq=%d', 22 | MOBILE_DOWNLOAD_INFO_MASK => '/tracks/%d/download-info', 23 | DOWNLOAD_PATH_MASK => 'https://%s/get-mp3/%s/%s?track-id=%s&from=service-10-track&similarities-experiment=default', 24 | PLAYLIST_INFO_MASK => '/handlers/playlist.jsx?owner=%s&kinds=%d&light=true&madeFor=&withLikesCount=true&lang=ru&external-domain=music.yandex.ru&overembed=false&ncrnd=', 25 | MOBILE_PLAYLIST_INFO_MASK => '/users/%s/playlists/%d', 26 | PLAYLIST_REQ_PART => '{"userFeed":"old","similarities":"default","genreRadio":"new-ichwill-matrixnet6","recommendedArtists":"ichwill_similar_artists","recommendedTracks":"recommended_tracks_by_artist_from_history","recommendedAlbumsOfFavoriteGenre":"recent","recommendedSimilarArtists":"default","recommendedArtistsWithArtistsFromHistory":"force_recent","adv":"a","loserArtistsWithArtists":"off","ny2015":"no"}', 27 | PLAYLIST_FULL_INFO => '/handlers/track-entries.jsx', 28 | ALBUM_INFO_MASK => '/api/v2.1/handlers/album/%d?external-domain=music.yandex.ru&overembed=no&__t=%d', 29 | MOBILE_ALBUM_INFO_MASK => '/albums/%d/with-tracks', 30 | LYRICS_MASK => '/handlers/track.jsx?track=%d:%d&lang=ru&external-domain=music.yandex.ru&overembed=false&ncrnd=%d', 31 | FILE_NAME_PATTERN => '#artist - #title', 32 | DEFAULT_PERMISSIONS => 755, 33 | # For more details refer to 'create_track_entry' function 34 | PATTERN_MP3TAGS_RELS => 35 | { 36 | 'number' => 'TRCK', 37 | 'artist' => 'TPE1', 38 | 'title' => 'TIT2', 39 | 'album' => 'TALB', 40 | 'year' => 'TYER', 41 | }, 42 | FILE_SAVE_EXT => '.mp3', 43 | COVER_RESOLUTION => '400x400', 44 | GENERIC_COLLECTION => "\x{441}\x{431}\x{43e}\x{440}\x{43d}\x{438}\x{43a}", 45 | GENERIC_TITLE => 'Various Artists', 46 | URL_ALBUM_REGEX => qr{music\.yandex\.\w+/album/(\d+)}is, 47 | URL_TRACK_REGEX => qr{music\.yandex\.\w+/album/(\d+)/track/(\d+)}is, 48 | URL_PLAYLIST_REGEX => qr{music\.yandex\.\w+/users/(.+?)/playlists/(\d+)}is, 49 | RESPONSE_LOG_PREFIX => 'log_', 50 | TEST_URL => 'https://api.music.yandex.net/users/ya.playlist/playlists/1', 51 | RENAME_ERRORS_MAX => 5, 52 | AUTH_TOKEN_PREFIX => 'OAuth ', 53 | COOKIE_PREFIX => 'Session_id=', 54 | HQ_BITRATE => '320', 55 | DEFAULT_CODEC => 'mp3', 56 | PODCAST_TYPE => 'podcast', 57 | VERSION => '1.5', 58 | COPYRIGHT => '© 2013-2023 by Kaimi (https://kaimi.io)', 59 | }; 60 | use constant 61 | { 62 | PLAYLIST_LIKE => 3, 63 | PLAYLIST_LIKE_TITLE => 'Мне нравится' 64 | }; 65 | use constant 66 | { 67 | DEBUG => 'DEBUG', 68 | ERROR => 'ERROR', 69 | INFO => 'INFO', 70 | OK => 'OK' 71 | }; 72 | use constant 73 | { 74 | WIN_UTF8_CODEPAGE => 65001, 75 | STD_OUTPUT_HANDLE => 0xFFFFFFF5, 76 | FG_BLUE => 1, 77 | FG_GREEN => 2, 78 | FG_RED => 4, 79 | BG_WHITE => 112, 80 | SZ_CONSOLE_FONT_INFOEX => 84, 81 | FF_DONTCARE => 0 << 4, 82 | FW_NORMAL => 400, 83 | COORD => 0x000c0000, 84 | FONT_NAME => 'Lucida Console' 85 | }; 86 | 87 | my %log_colors = 88 | ( 89 | &DEBUG => 90 | { 91 | nix => 'red on_white', 92 | win => FG_RED | BG_WHITE 93 | }, 94 | &ERROR => 95 | { 96 | nix => 'red', 97 | win => FG_RED 98 | }, 99 | &INFO => 100 | { 101 | nix => 'blue on_white', 102 | win => FG_BLUE | BG_WHITE 103 | }, 104 | &OK => 105 | { 106 | nix => 'green on_white', 107 | win => FG_GREEN | BG_WHITE 108 | } 109 | ); 110 | 111 | my %req_modules = 112 | ( 113 | NIX => [], 114 | WIN => [ qw/Win32::API Win32API::File Win32::Console/ ], 115 | ALL => [ qw/Mozilla::CA Digest::MD5 File::Copy File::Spec File::Temp File::Util MP3::Tag JSON::PP Getopt::Long::Descriptive Term::ANSIColor LWP::UserAgent LWP::Protocol::https HTTP::Cookies HTML::Entities/ ] 116 | ); 117 | 118 | $\ = NL; 119 | 120 | my @missing_modules; 121 | for my $module(@{$req_modules{ALL}}, IS_WIN ? @{$req_modules{WIN}} : @{$req_modules{NIX}}) 122 | { 123 | # Suppress MP3::Tag deprecated regex and other warnings 124 | eval "local \$SIG{'__WARN__'} = sub {}; require $module"; 125 | if($@) 126 | { 127 | push @missing_modules, $module; 128 | } 129 | } 130 | 131 | if(@missing_modules) 132 | { 133 | print 'Please, install this modules: ' . join ', ', @missing_modules; 134 | exit(1); 135 | } 136 | 137 | # PAR issue workaround && different win* approach for Unicode output 138 | if(IS_WIN) 139 | { 140 | binmode STDOUT, ':unix:utf8'; 141 | # Unicode (UTF-8) codepage 142 | Win32::Console::OutputCP(WIN_UTF8_CODEPAGE); 143 | $main::console = Win32::Console->new(STD_OUTPUT_HANDLE); 144 | 145 | # Set console font with Unicode support (only for Vista+ OS) 146 | if((Win32::GetOSVersion())[1] eq 6) 147 | { 148 | # FaceName size = LF_FACESIZE 149 | Win32::API::Struct->typedef 150 | ( 151 | CONSOLE_FONT_INFOEX => 152 | qw 153 | { 154 | ULONG cbSize; 155 | DWORD nFont; 156 | DWORD dwFontSize; 157 | UINT FontFamily; 158 | UINT FontWeight; 159 | WCHAR FaceName[32]; 160 | } 161 | ); 162 | 163 | Win32::API->Import 164 | ( 165 | 'kernel32', 166 | 'HANDLE WINAPI GetStdHandle(DWORD nStdHandle)' 167 | ); 168 | Win32::API->Import 169 | ( 170 | 'kernel32', 171 | 'BOOL WINAPI SetCurrentConsoleFontEx(HANDLE hConsoleOutput, BOOL bMaximumWindow, LPCONSOLE_FONT_INFOEX lpConsoleCurrentFontEx)' 172 | ); 173 | 174 | my $font = Win32::API::Struct->new('CONSOLE_FONT_INFOEX'); 175 | 176 | $font->{cbSize} = SZ_CONSOLE_FONT_INFOEX; 177 | $font->{nFont} = 0; 178 | $font->{dwFontSize} = COORD; # COORD struct wrap 179 | $font->{FontFamily} = FF_DONTCARE; 180 | $font->{FontWeight} = FW_NORMAL; 181 | $font->{FaceName} = Encode::encode('UTF-16LE', FONT_NAME); 182 | 183 | SetCurrentConsoleFontEx(GetStdHandle(STD_OUTPUT_HANDLE), 0, $font); 184 | } 185 | } 186 | else 187 | { 188 | binmode STDOUT, ':encoding(utf8)'; 189 | } 190 | 191 | my ($opt, $usage) = Getopt::Long::Descriptive::describe_options 192 | ( 193 | 'Yandex Music Downloader v' . VERSION . NL . NL . 194 | basename(__FILE__).' %o', 195 | ['playlist|p:i', 'playlist id to download'], 196 | ['kind|k:s', 'playlist kind (eg. ya-playlist, music-blog, music-partners, etc.)'], 197 | ['album|a:i', 'album to download'], 198 | ['track|t:i', 'track to download (album id must be specified)'], 199 | ['url|u:s', 'download by URL'], 200 | ['dir|d:s', 'download path (current direcotry will be used by default)', {default => '.'}], 201 | ['skip-existing', 'skip downloading tracks that already exist on the specified path'], 202 | ['proxy=s', 'HTTP-proxy (format: 1.2.3.4:8888)'], 203 | ['exclude=s', 'skip tracks specified in file'], 204 | ['include=s', 'download only tracks specified in file'], 205 | ['delay=i', 'delay between downloads (in seconds)', {default => 5}], 206 | ['mobile=i', 'use mobile API', {default => 0}], 207 | ['auth=s', 'authorization header for mobile version (OAuth...)'], 208 | ['cookie=s', 'authorization cookie for web version (Session_id=...)'], 209 | ['bitrate=i', 'bitrate (eg. 64, 128, 192, 320)'], 210 | ['pattern=s', 'track naming pattern', {default => FILE_NAME_PATTERN}], 211 | ['path=s', 'path saving pattern', {default => ''}], 212 | [], 213 | ['Available placeholders: #number, #artist, #title, #album, #year'], 214 | [], 215 | ['Path pattern will be used in addition to the download path directory'], 216 | [], 217 | ['Example path pattern: #artist/#album-#year'], 218 | [], 219 | ['link|l', 'do not fetch, only print links to the tracks'], 220 | ['silent|s', 'do not print informational messages'], 221 | ['debug', 'print debug info during work'], 222 | ['help|h', 'print usage'], 223 | [], 224 | ['--include and --exclude options use weak match i.e. ~/$term/'], 225 | [], 226 | ['Example: '], 227 | [basename(__FILE__) . ' -p 123 -k ya-playlist'], 228 | [basename(__FILE__) . ' -a 123'], 229 | [basename(__FILE__) . ' -a 123 -t 321'], 230 | [basename(__FILE__) . ' -u https://music.yandex.ru/album/215690 --cookie ...'], 231 | [basename(__FILE__) . ' -u https://music.yandex.ru/album/215688/track/1710808 --auth ...'], 232 | [basename(__FILE__) . ' -u https://music.yandex.ru/users/ya.playlist/playlists/1257 --cookie ...'], 233 | [], 234 | [COPYRIGHT] 235 | ); 236 | 237 | # Get a modifiable options copy 238 | my %opt = %{$opt}; 239 | 240 | if( $opt{help} || ( !$opt{url} && !($opt{track} && $opt{album}) && !$opt{album} && !($opt{playlist} && $opt{kind}) ) ) 241 | { 242 | print $usage->text; 243 | exit(0); 244 | } 245 | 246 | if(!$opt{auth} && !$opt{cookie}) 247 | { 248 | info(ERROR, 'Please, specify either mobile app auth header value (--auth) or web version auth cookie (--cookie)'); 249 | info(ERROR, 'It is no longer possible to download full version of tracks without authentication'); 250 | exit(1); 251 | } 252 | 253 | if($opt{mobile} && !$opt{auth} && $opt{cookie}) 254 | { 255 | info(ERROR, 'Please, provide --auth instead of --cookie for Mobile API'); 256 | exit(1); 257 | } 258 | 259 | if(!$opt{mobile} && $opt{auth} && !$opt{cookie}) 260 | { 261 | info(ERROR, 'Please, provide --cookie instead of --auth for Web API'); 262 | exit(1); 263 | } 264 | 265 | if($opt{dir} && !-d $opt{dir}) 266 | { 267 | info(ERROR, 'Please, specify an existing directory'); 268 | exit(1); 269 | } 270 | 271 | # Fix for "Writing of ID3v2.4 is not fully supported (prohibited now via `write_v24')" 272 | MP3::Tag->config(write_v24 => 1); 273 | MP3::Tag->config(id3v23_unsync => 0); 274 | MP3::Tag->config(decode_encoding_v2 => 'UTF-8'); 275 | 276 | my $ua = LWP::UserAgent->new 277 | ( 278 | agent => $opt{mobile} ? MOBILE_AGENT : AGENT, 279 | default_headers => HTTP::Headers->new 280 | ( 281 | X_Retpath_Y => 1 282 | ), 283 | cookie_jar => HTTP::Cookies->new 284 | ( 285 | hide_cookie2 => 1 286 | ), 287 | timeout => TIMEOUT, 288 | ssl_opts => 289 | { 290 | verify_hostname => $opt{debug} ? 0 : 1, 291 | SSL_verify_mode => $opt{debug} ? IO::Socket::SSL->SSL_VERIFY_NONE : IO::Socket::SSL->SSL_VERIFY_PEER, 292 | }, 293 | send_te => 0 294 | ); 295 | 296 | # Fix auth token and cookie format if required 297 | my $auth_token = ''; 298 | if($opt{mobile} && $opt{auth}) 299 | { 300 | if($opt{auth} !~ /${\(AUTH_TOKEN_PREFIX)}/i) 301 | { 302 | $auth_token = AUTH_TOKEN_PREFIX; 303 | } 304 | $auth_token .= $opt{auth}; 305 | 306 | $ua->default_header(Authorization => $auth_token); 307 | } 308 | 309 | my $cookie = ''; 310 | if(!$opt{mobile} && $opt{cookie}) 311 | { 312 | if($opt{cookie} !~ /${\(COOKIE_PREFIX)}/i) 313 | { 314 | $cookie = COOKIE_PREFIX; 315 | } 316 | $cookie .= $opt{cookie}; 317 | 318 | $ua->default_header(Cookie => $cookie); 319 | } 320 | 321 | my ($whole_file, $total_size); 322 | 323 | my $json_decoder = JSON::PP->new->utf8->pretty->allow_nonref->allow_singlequote; 324 | my @exclude = (); 325 | my @include = (); 326 | 327 | if($opt{debug}) 328 | { 329 | print_debug_info(); 330 | } 331 | 332 | if($opt{proxy}) 333 | { 334 | $ua->proxy(['http', 'https'], 'http://' . $opt{proxy} . '/'); 335 | } 336 | 337 | if($opt{exclude}) 338 | { 339 | @exclude = read_file($opt{exclude}); 340 | } 341 | 342 | if($opt{include}) 343 | { 344 | @include = read_file($opt{include}); 345 | } 346 | 347 | if($opt{url}) 348 | { 349 | if($opt{url} =~ URL_TRACK_REGEX) 350 | { 351 | $opt{album} = $1; 352 | $opt{track} = $2; 353 | } 354 | elsif($opt{url} =~ URL_ALBUM_REGEX) 355 | { 356 | $opt{album} = $1; 357 | } 358 | elsif($opt{url} =~ URL_PLAYLIST_REGEX) 359 | { 360 | $opt{kind} = $1; 361 | $opt{playlist} = $2; 362 | } 363 | else 364 | { 365 | info(ERROR, 'Invalid URL format'); 366 | } 367 | } 368 | 369 | if($opt{album} || ($opt{playlist} && $opt{kind})) 370 | { 371 | my @track_list_info; 372 | =pod 373 | info(INFO, 'Checking Yandex.Music availability'); 374 | 375 | my $request = $ua->get(TEST_URL); 376 | if($request->code != 404) 377 | { 378 | info(ERROR, 'Yandex.Music is not available'); 379 | exit(1); 380 | } 381 | else 382 | { 383 | info(OK, 'Yandex.Music is available') 384 | } 385 | =cut 386 | if($opt{album}) 387 | { 388 | info(INFO, 'Fetching album info: ' . $opt{album}); 389 | 390 | @track_list_info = get_album_tracks_info($opt{album}); 391 | 392 | if(scalar @track_list_info > 0 && $opt{track}) 393 | { 394 | info(INFO, 'Filtering single track: ' . $opt{track} . ' [' . $opt{album} . ']'); 395 | @track_list_info = grep 396 | ( 397 | $_->{track_id} eq $opt{track} 398 | , 399 | @track_list_info 400 | ); 401 | } 402 | } 403 | else 404 | { 405 | info(INFO, 'Fetching playlist info: ' . $opt{playlist} . ' [' . $opt{kind} . ']'); 406 | 407 | @track_list_info = get_playlist_tracks_info($opt{playlist}); 408 | } 409 | 410 | 411 | if(!@track_list_info) 412 | { 413 | info(ERROR, 'Can\'t get track list info'); 414 | exit(1); 415 | } 416 | 417 | for my $track_info_ref(@track_list_info) 418 | { 419 | my $skip = 0; 420 | for my $title(@exclude) 421 | { 422 | if($track_info_ref->{title} =~ /\Q$title\E/) 423 | { 424 | $skip = 1; 425 | last; 426 | } 427 | } 428 | 429 | if($opt{skip_existing} && track_file_exists($track_info_ref)) 430 | { 431 | $skip = 1; 432 | } 433 | 434 | if($skip) 435 | { 436 | info(INFO, 'Skipping: ' . $track_info_ref->{title}); 437 | next; 438 | } 439 | 440 | $skip = 1; 441 | for my $title(@include) 442 | { 443 | if($track_info_ref->{title} =~ /\Q$title\E/) 444 | { 445 | $skip = 0; 446 | last; 447 | } 448 | } 449 | if($skip && $opt{include}) 450 | { 451 | info(INFO, 'Skipping: ' . $track_info_ref->{title}); 452 | next; 453 | } 454 | 455 | if(!$track_info_ref->{title}) 456 | { 457 | info(ERROR, 'Track with non-existent title. Skipping...'); 458 | next; 459 | } 460 | 461 | if($opt{link}) 462 | { 463 | print(get_track_url($track_info_ref)); 464 | } 465 | else 466 | { 467 | fetch_track($track_info_ref); 468 | 469 | if($opt{delay} && $track_info_ref != $track_list_info[-1]) 470 | { 471 | info(INFO, 'Waiting for ' . $opt{delay} . ' seconds'); 472 | sleep $opt{delay}; 473 | } 474 | } 475 | } 476 | 477 | info(OK, 'Done!'); 478 | } 479 | 480 | if(IS_WIN) 481 | { 482 | $main::console->Free(); 483 | } 484 | 485 | sub fetch_track 486 | { 487 | my $track_info_ref = shift; 488 | 489 | $track_info_ref->{title} =~ s/\s+$//; 490 | $track_info_ref->{title} =~ s/[\\\/:"*?<>|]+/-/g; 491 | 492 | info(INFO, 'Trying to fetch track: '.$track_info_ref->{title}); 493 | 494 | my $track_url = get_track_url($track_info_ref); 495 | if(!$track_url) 496 | { 497 | info(ERROR, 'Can\'t get track url'); 498 | return; 499 | } 500 | 501 | my $file_path = download_track($track_url); 502 | if(!$file_path) 503 | { 504 | info(ERROR, 'Failed to download track'); 505 | return; 506 | } 507 | 508 | info(OK, 'Temporary saved track at '.$file_path); 509 | 510 | fetch_album_cover($track_info_ref->{mp3tags}); 511 | fetch_track_lyrics($track_info_ref); 512 | 513 | if(write_mp3_tags($file_path, $track_info_ref->{mp3tags})) 514 | { 515 | info(INFO, 'MP3 tags added for ' . $file_path); 516 | } 517 | else 518 | { 519 | info(ERROR, 'Failed to add MP3 tags for ' . $file_path); 520 | } 521 | 522 | my $target_path = create_storage_path($track_info_ref); 523 | if(!$target_path) 524 | { 525 | info(ERROR, 'Failed to create: ' . $target_path); 526 | return; 527 | } 528 | 529 | $target_path = File::Spec->catfile($target_path, $track_info_ref->{title} . FILE_SAVE_EXT); 530 | 531 | if(rename_track($file_path, $target_path)) 532 | { 533 | info(INFO, $file_path . ' -> ' . $target_path); 534 | } 535 | else 536 | { 537 | info(ERROR, $file_path . ' -> ' . $target_path); 538 | } 539 | } 540 | 541 | sub create_storage_path 542 | { 543 | my $track_info_ref = shift; 544 | 545 | my $target_path = get_storage_path($track_info_ref); 546 | 547 | my $file_util = File::Util->new(); 548 | if(!-d $file_util->make_dir($target_path => oct DEFAULT_PERMISSIONS => {if_not_exists => 1})) 549 | { 550 | return; 551 | } 552 | 553 | return $target_path; 554 | } 555 | 556 | sub track_file_exists 557 | { 558 | my $track_info_ref = shift; 559 | 560 | my $target_path = get_storage_path($track_info_ref); 561 | $target_path = File::Spec->catfile($target_path, $track_info_ref->{title} . FILE_SAVE_EXT); 562 | 563 | return -e $target_path; 564 | } 565 | 566 | sub get_storage_path 567 | { 568 | my $track_info_ref = shift; 569 | 570 | my $target_path = $opt{dir}; 571 | if($opt{path}) 572 | { 573 | $target_path = File::Spec->catdir($target_path, $track_info_ref->{storage_path}); 574 | } 575 | 576 | return $target_path; 577 | } 578 | 579 | sub download_track 580 | { 581 | my ($url) = @_; 582 | 583 | my $request = $ua->head($url); 584 | if(!$request->is_success) 585 | { 586 | info(DEBUG, 'Request failed'); 587 | log_response($request); 588 | return; 589 | } 590 | 591 | $whole_file = ''; 592 | $total_size = $request->headers->content_length; 593 | 594 | info(DEBUG, 'File size from header: ' . $total_size); 595 | 596 | $request = $ua->get($url, ':content_cb' => \&progress); 597 | if(!$request->is_success) 598 | { 599 | info(DEBUG, 'Request failed'); 600 | log_response($request); 601 | return; 602 | } 603 | 604 | my ($file_handle, $file_path) = File::Temp::tempfile(DIR => $opt{dir}); 605 | return unless $file_handle; 606 | 607 | binmode $file_handle; 608 | # Autoflush file contents 609 | select((select($file_handle),$|=1)[0]); 610 | { 611 | local $\ = undef; 612 | print $file_handle $whole_file; 613 | } 614 | 615 | my $disk_data_size = (stat($file_handle))[7]; 616 | close $file_handle; 617 | 618 | if($total_size && $disk_data_size != $total_size) 619 | { 620 | info(DEBUG, 'Actual file size differs from expected ('.$disk_data_size.'/'.$total_size.')'); 621 | } 622 | 623 | return $file_path; 624 | } 625 | 626 | sub get_track_url 627 | { 628 | my $track_info_ref = shift; 629 | 630 | my $album_id = $track_info_ref->{album_id}; 631 | my $track_id = $track_info_ref->{track_id}; 632 | my $is_hq = ($opt{bitrate} && ($opt{bitrate} eq HQ_BITRATE)) ? 1 : 0; 633 | # Get track path information 634 | my $request = $ua->get 635 | ( 636 | $opt{mobile} ? 637 | MOBILE_YANDEX_BASE.sprintf(MOBILE_DOWNLOAD_INFO_MASK, $track_id) 638 | : 639 | YANDEX_BASE.sprintf(DOWNLOAD_INFO_MASK, $track_id, $album_id, time, $is_hq) 640 | ); 641 | if(!$request->is_success) 642 | { 643 | info(DEBUG, 'Request failed'); 644 | log_response($request); 645 | return; 646 | } 647 | 648 | my ($json_data) = $request->content; 649 | if(!$json_data) 650 | { 651 | info(DEBUG, 'Can\'t parse JSON blob'); 652 | log_response($request); 653 | return; 654 | } 655 | 656 | my $json = create_json($json_data); 657 | if(!$json) 658 | { 659 | info(DEBUG, 'Can\'t create json from data'); 660 | log_response($request); 661 | return; 662 | } 663 | 664 | # Pick specified bitrate or highest available 665 | my $url; 666 | if($opt{mobile}) 667 | { 668 | # Sort by available bitrate (highest first) 669 | @{$json->{result}} = sort { $b->{bitrateInKbps} <=> $a->{bitrateInKbps} } @{$json->{result}}; 670 | 671 | my ($idx, $target_idx) = (0, -1); 672 | for my $track_info(@{$json->{result}}) 673 | { 674 | if($track_info->{codec} eq DEFAULT_CODEC) 675 | { 676 | if($opt{bitrate} && $track_info->{bitrateInKbps} == $opt{bitrate}) 677 | { 678 | $target_idx = $idx; 679 | last; 680 | } 681 | elsif(!$opt{bitrate}) 682 | { 683 | $target_idx = $idx; 684 | last; 685 | } 686 | } 687 | 688 | $idx++; 689 | } 690 | 691 | if($target_idx < 0) 692 | { 693 | info(DEBUG, 'Can\'t find track with proper format & bitrate'); 694 | log_response($request); 695 | return; 696 | } 697 | 698 | $url = @{$json->{result}}[$target_idx]->{downloadInfoUrl}; 699 | } 700 | else 701 | { 702 | $url = $json->{src}; 703 | } 704 | 705 | $url = 'https:' . $url unless $url =~ /^https:/; 706 | $request = $ua->get($url); 707 | if(!$request->is_success) 708 | { 709 | info(DEBUG, 'Request failed'); 710 | log_response($request); 711 | return; 712 | } 713 | 714 | # No proper XML parsing cause it will break soon 715 | my %fields = ($request->content =~ /<(\w+)>([^<]+?)<\/\w+>/g); 716 | 717 | my $hash = Digest::MD5::md5_hex(MD5_SALT . substr($fields{path}, 1) . $fields{s}); 718 | $url = sprintf(DOWNLOAD_PATH_MASK, $fields{host}, $hash, $fields{ts}.$fields{path}, $track_id); 719 | 720 | info(DEBUG, 'Track url: ' . $url); 721 | 722 | return $url; 723 | } 724 | 725 | sub get_album_tracks_info 726 | { 727 | my $album_id = shift; 728 | 729 | my $request = $ua->get 730 | ( 731 | $opt{mobile} ? 732 | MOBILE_YANDEX_BASE.sprintf(MOBILE_ALBUM_INFO_MASK, $album_id) 733 | : 734 | YANDEX_BASE.sprintf(ALBUM_INFO_MASK, $album_id, time) 735 | ); 736 | if(!$request->is_success) 737 | { 738 | info(DEBUG, 'Request failed'); 739 | log_response($request); 740 | return; 741 | } 742 | 743 | 744 | my ($json_data) = $request->content; 745 | if(!$json_data) 746 | { 747 | info(DEBUG, 'Can\'t parse JSON blob'); 748 | log_response($request); 749 | return; 750 | } 751 | 752 | my $json = create_json($json_data); 753 | if(!$json) 754 | { 755 | info(DEBUG, 'Can\'t create json from data: ' . $@); 756 | log_response($request); 757 | return; 758 | } 759 | 760 | # "Rebase" JSON 761 | $json = $opt{mobile} ? $json->{'result'} : $json; 762 | 763 | my $title = $json->{title}; 764 | if(!$title) 765 | { 766 | info(DEBUG, 'Can\'t get album title'); 767 | return; 768 | } 769 | 770 | info(INFO, 'Album title: ' . $title); 771 | info(INFO, 'Tracks total: ' . $json->{trackCount}); 772 | 773 | if($opt{mobile} && !$json->{availableForMobile}) 774 | { 775 | info(ERROR, 'Album is not available via Mobile API'); 776 | return; 777 | } 778 | 779 | my @tracks = (); 780 | for my $vol(@{$json->{volumes}}) 781 | { 782 | for my $track(@{$vol}) 783 | { 784 | if(!$track->{error}) 785 | { 786 | push @tracks, create_track_entry($track, 0); 787 | } 788 | } 789 | } 790 | 791 | return @tracks; 792 | } 793 | 794 | sub get_playlist_tracks_info 795 | { 796 | my $playlist_id = shift; 797 | 798 | my $request = $ua->get 799 | ( 800 | $opt{mobile} ? 801 | MOBILE_YANDEX_BASE.sprintf(MOBILE_PLAYLIST_INFO_MASK, $opt{kind}, $playlist_id) 802 | : 803 | YANDEX_BASE.sprintf(PLAYLIST_INFO_MASK, $opt{kind}, $playlist_id) 804 | ); 805 | if(!$request->is_success) 806 | { 807 | info(DEBUG, 'Request failed'); 808 | log_response($request); 809 | return; 810 | } 811 | 812 | my ($json_data) = $request->content; 813 | if(!$json_data) 814 | { 815 | info(DEBUG, 'Can\'t parse JSON blob'); 816 | log_response($request); 817 | return; 818 | } 819 | 820 | my $json = create_json($json_data); 821 | if(!$json) 822 | { 823 | info(DEBUG, 'Can\'t create json from data: ' . $@); 824 | log_response($request); 825 | return; 826 | } 827 | 828 | my $title = $opt{mobile} 829 | ? 830 | ( $opt{playlist} == PLAYLIST_LIKE ? PLAYLIST_LIKE_TITLE : $json->{result}->{title} ) 831 | : 832 | $json->{playlist}->{title}; 833 | 834 | if(!$title) 835 | { 836 | info(DEBUG, 'Can\'t get playlist title'); 837 | return; 838 | } 839 | 840 | info(INFO, 'Playlist title: ' . $title); 841 | info 842 | ( 843 | INFO, 844 | 'Tracks total: ' . 845 | ( 846 | $opt{mobile} ? 847 | $json->{result}->{trackCount} 848 | : 849 | $json->{playlist}->{trackCount} 850 | ) 851 | ); 852 | 853 | my @tracks_info; 854 | my $track_number = 1; 855 | 856 | if(!$opt{mobile} && $json->{playlist}->{trackIds}) 857 | { 858 | my @playlist_chunks; 859 | my $tracks_ref = $json->{playlist}->{trackIds}; 860 | my $sign = $json->{authData}->{user}->{sign}; 861 | 862 | push @playlist_chunks, [splice @{$tracks_ref}, 0, 150] while @{$tracks_ref}; 863 | 864 | for my $chunk(@playlist_chunks) 865 | { 866 | $request = $ua->post 867 | ( 868 | YANDEX_BASE.PLAYLIST_FULL_INFO, 869 | { 870 | strict => 'true', 871 | sign => $sign, 872 | lang => 'ru', 873 | experiments => PLAYLIST_REQ_PART, 874 | entries => join ',', @{$chunk} 875 | } 876 | ); 877 | 878 | if(!$request->is_success) 879 | { 880 | info(DEBUG, 'Request failed'); 881 | log_response($request); 882 | return; 883 | } 884 | 885 | $json = create_json($request->content); 886 | if(!$json) 887 | { 888 | info(DEBUG, 'Can\'t create json from data'); 889 | log_response($request); 890 | return; 891 | } 892 | 893 | push @tracks_info, 894 | map 895 | { 896 | create_track_entry($_, $track_number++) 897 | } grep { !$_->{error} } @{ $json }; 898 | } 899 | } 900 | else 901 | { 902 | @tracks_info = map 903 | { 904 | create_track_entry 905 | ( 906 | $opt{mobile} ? 907 | $_->{track} 908 | : 909 | $_ 910 | , $track_number++ 911 | ) 912 | } 913 | grep { !$_->{error} } 914 | @ 915 | { 916 | $opt{mobile} ? 917 | $json->{result}->{tracks} 918 | : 919 | $json->{playlist}->{tracks} 920 | }; 921 | } 922 | 923 | return @tracks_info; 924 | } 925 | 926 | sub create_track_entry 927 | { 928 | my ($track_info, $track_number) = @_; 929 | 930 | # Better detection algo? 931 | my $is_part_of_album = scalar @{$track_info->{albums}} != 0; 932 | 933 | my $is_various; 934 | if 935 | ( 936 | exists $track_info->{albums}->[0]->{metaType} 937 | && 938 | $track_info->{albums}->[0]->{metaType} ne PODCAST_TYPE 939 | ) 940 | { 941 | $is_various = 942 | scalar @{$track_info->{artists}} > 1 943 | || 944 | ($is_part_of_album && $track_info->{albums}->[0]->{artists}->[0]->{name} eq GENERIC_COLLECTION) 945 | ; 946 | } 947 | 948 | # TALB - album title; TPE2 - album artist; 949 | # APIC - album picture; TYER - year; 950 | # TIT2 - song title; TPE1 - song artist; 951 | # TCON - track genre; TRCK - track number 952 | # USLT - unsychronised lyrics 953 | my %mp3_tags = (); 954 | # Special case for podcasts 955 | if($track_info->{albums}->[0]->{metaType} eq PODCAST_TYPE) 956 | { 957 | $mp3_tags{TPE1} = $track_info->{albums}->[0]->{title}; 958 | } 959 | else 960 | { 961 | $mp3_tags{TPE1} = join ', ', map { $_->{name} } @{$track_info->{artists}}; 962 | } 963 | $mp3_tags{TIT2} = $track_info->{title}; 964 | # No track number info in JSON if fetching from anything but album 965 | if($track_number) 966 | { 967 | $mp3_tags{TRCK} = $track_number; 968 | } 969 | else 970 | { 971 | $mp3_tags{TRCK} = $track_info->{albums}->[0]->{trackPosition}->{index}; 972 | } 973 | 974 | # Append track postfix (like remix) if present 975 | if(exists $track_info->{version}) 976 | { 977 | $mp3_tags{TIT2} .= "\x20" . '(' . $track_info->{version} . ')'; 978 | } 979 | 980 | # For deleted tracks 981 | if($is_part_of_album) 982 | { 983 | $mp3_tags{TALB} = $track_info->{albums}->[0]->{title}; 984 | if($track_info->{albums}->[0]->{metaType} eq PODCAST_TYPE) 985 | { 986 | $mp3_tags{TPE2} = $mp3_tags{TALB}; 987 | } 988 | else 989 | { 990 | $mp3_tags{TPE2} = $is_various ? GENERIC_TITLE : $track_info->{albums}->[0]->{artists}->[0]->{name}; 991 | } 992 | # 'Dummy' cover for post-process 993 | $mp3_tags{APIC} = $track_info->{albums}->[0]->{coverUri}; 994 | $mp3_tags{TYER} = $track_info->{albums}->[0]->{year}; 995 | $mp3_tags{TCON} = $track_info->{albums}->[0]->{genre}; 996 | } 997 | 998 | # Substitute placeholders within a track name and a path name 999 | my $track_filename = $opt{pattern}; 1000 | my $storage_path = $opt{path}; 1001 | while (my ($pattern, $tag_id) = each %{&PATTERN_MP3TAGS_RELS}) 1002 | { 1003 | $track_filename =~ s/\#$pattern/$mp3_tags{$tag_id}/gi; 1004 | $storage_path =~ s/\#$pattern/$mp3_tags{$tag_id}/gi; 1005 | } 1006 | 1007 | return 1008 | { 1009 | # Album id 1010 | album_id => $track_info->{albums}->[0]->{id}, 1011 | # Track id 1012 | track_id => $track_info->{id}, 1013 | # MP3 tags 1014 | mp3tags => \%mp3_tags, 1015 | # 'Save As' file name 1016 | title => $track_filename, 1017 | # 'Save As' directory 1018 | storage_path => $storage_path, 1019 | }; 1020 | } 1021 | 1022 | sub write_mp3_tags 1023 | { 1024 | my ($file_path, $mp3tags) = @_; 1025 | 1026 | my $mp3 = MP3::Tag->new($file_path); 1027 | if(!$mp3) 1028 | { 1029 | info(DEBUG, 'Can\'t create MP3::Tag object: ' . $@); 1030 | return; 1031 | } 1032 | 1033 | $mp3->new_tag('ID3v2'); 1034 | 1035 | while(my ($frame, $data) = each %{$mp3tags}) 1036 | { 1037 | # Skip empty 1038 | if($data) 1039 | { 1040 | info(DEBUG, 'add_frame: ' . $frame . '=' . substr $data, 0, 16); 1041 | 1042 | $mp3->{ID3v2}->add_frame 1043 | ( 1044 | $frame, 1045 | ref $data eq ref [] ? @{$data} : $data 1046 | ); 1047 | } 1048 | } 1049 | 1050 | $mp3->{ID3v2}->write_tag; 1051 | $mp3->close(); 1052 | 1053 | return 1; 1054 | } 1055 | 1056 | sub fetch_album_cover 1057 | { 1058 | my $mp3tags = shift; 1059 | 1060 | my $cover_url = $mp3tags->{APIC}; 1061 | if(!$cover_url) 1062 | { 1063 | info(DEBUG, 'Empty cover URL'); 1064 | return; 1065 | } 1066 | 1067 | # Normalize url 1068 | $cover_url =~ s/%%/${\(COVER_RESOLUTION)}/; 1069 | $cover_url = 'https://' . $cover_url; 1070 | 1071 | info(DEBUG, 'Cover URL: ' . $cover_url); 1072 | 1073 | my $request = $ua->get($cover_url); 1074 | if(!$request->is_success) 1075 | { 1076 | info(DEBUG, 'Request failed'); 1077 | log_response($request); 1078 | undef $mp3tags->{APIC}; 1079 | return; 1080 | } 1081 | 1082 | $mp3tags->{APIC} = [chr(0x0), 'image/jpg', chr(0x0), 'Cover (front)', $request->content]; 1083 | } 1084 | 1085 | sub fetch_track_lyrics 1086 | { 1087 | my $track_info_ref = shift; 1088 | 1089 | my $mp3tags = $track_info_ref->{mp3tags}; 1090 | my $lyrics_url = YANDEX_BASE.sprintf(LYRICS_MASK, $track_info_ref->{track_id}, $track_info_ref->{album_id}, time); 1091 | 1092 | info(DEBUG, 'Lyrics URL: ' . $lyrics_url); 1093 | 1094 | my $request = $ua->get($lyrics_url); 1095 | if(!$request->is_success) 1096 | { 1097 | info(DEBUG, 'Request failed'); 1098 | log_response($request); 1099 | return; 1100 | } 1101 | 1102 | my ($json_data) = $request->content; 1103 | if(!$json_data) 1104 | { 1105 | info(DEBUG, 'Can\'t parse JSON blob'); 1106 | log_response($request); 1107 | return; 1108 | } 1109 | 1110 | my $json = create_json($json_data); 1111 | if(!$json) 1112 | { 1113 | info(DEBUG, 'Can\'t create json from data'); 1114 | log_response($request); 1115 | return; 1116 | } 1117 | 1118 | if($json->{lyricsAvailable}) 1119 | { 1120 | my $lyrics = $json->{lyric}->[0]->{fullLyrics}; 1121 | # Encoding flag explanation: $03 UTF-8 [UTF-8] 1122 | $mp3tags->{USLT} = [3, 'eng', undef, $lyrics]; 1123 | } 1124 | } 1125 | 1126 | sub rename_track 1127 | { 1128 | my ($src_path, $dst_path) = @_; 1129 | 1130 | my ($src_fh, $dst_fh, $is_open_success, $errors) = (undef, undef, 1, 0); 1131 | 1132 | if(IS_WIN) 1133 | { 1134 | # Extend path limit to 32767 1135 | $dst_path = '\\\\?\\' . File::Spec->rel2abs($dst_path); 1136 | } 1137 | 1138 | for(;;) 1139 | { 1140 | if($errors >= RENAME_ERRORS_MAX) 1141 | { 1142 | info(DEBUG, 'File manipulations failed'); 1143 | last; 1144 | } 1145 | 1146 | if(!$is_open_success) 1147 | { 1148 | close $src_fh if $src_fh; 1149 | close $dst_fh if $dst_fh; 1150 | unlink $src_path if -e $src_path; 1151 | 1152 | last; 1153 | } 1154 | 1155 | $is_open_success = open($src_fh, '<', $src_path); 1156 | if(!$is_open_success) 1157 | { 1158 | info(DEBUG, 'Can\'t open src_path: ' . $src_path); 1159 | $errors++; 1160 | redo; 1161 | } 1162 | 1163 | if(IS_WIN) 1164 | { 1165 | my $unicode_path = Encode::encode('UTF-16LE', $dst_path); 1166 | Encode::_utf8_off($unicode_path); 1167 | $unicode_path .= "\x00\x00"; 1168 | # GENERIC_WRITE, OPEN_ALWAYS 1169 | my $native_handle = Win32API::File::CreateFileW($unicode_path, 0x40000000, 0, [], 2, 0, 0); 1170 | # ERROR_ALREADY_EXISTS 1171 | if($^E && $^E != 183) 1172 | { 1173 | info(DEBUG, 'CreateFileW failed with: ' . $^E); 1174 | $errors++; 1175 | redo; 1176 | } 1177 | 1178 | $is_open_success = Win32API::File::OsFHandleOpen($dst_fh = IO::Handle->new(), $native_handle, 'w'); 1179 | if(!$is_open_success) 1180 | { 1181 | info(DEBUG, 'OsFHandleOpen failed with: ' . $!); 1182 | $errors++; 1183 | redo; 1184 | } 1185 | } 1186 | else 1187 | { 1188 | $is_open_success = open($dst_fh, '>', $dst_path); 1189 | if(!$is_open_success) 1190 | { 1191 | info(DEBUG, 'Can\'t open dst_path: ' . $dst_path); 1192 | $errors++; 1193 | redo; 1194 | } 1195 | } 1196 | 1197 | if(!File::Copy::copy($src_fh, $dst_fh)) 1198 | { 1199 | $is_open_success = 0; 1200 | info(DEBUG, 'File::Copy::copy failed with: ' . $!); 1201 | $errors++; 1202 | redo; 1203 | } 1204 | 1205 | close $src_fh; 1206 | close $dst_fh; 1207 | 1208 | unlink $src_path; 1209 | 1210 | return 1; 1211 | } 1212 | 1213 | return 0; 1214 | } 1215 | 1216 | sub create_json 1217 | { 1218 | my $json_data = shift; 1219 | 1220 | my $json; 1221 | eval 1222 | { 1223 | $json = $json_decoder->decode($json_data); 1224 | }; 1225 | 1226 | if($@) 1227 | { 1228 | info(DEBUG, 'Error decoding json ' . $@); 1229 | return; 1230 | } 1231 | 1232 | HTML::Entities::decode_entities($json_data); 1233 | 1234 | return $json; 1235 | } 1236 | 1237 | sub info 1238 | { 1239 | my ($type, $msg) = @_; 1240 | 1241 | if($opt{silent} && $type ne ERROR) 1242 | { 1243 | return; 1244 | } 1245 | 1246 | if($type eq DEBUG) 1247 | { 1248 | return if !$opt{debug}; 1249 | # Func, line, msg 1250 | $msg = (caller(1))[3] . "(" . (caller(0))[2] . "): " . $msg; 1251 | } 1252 | 1253 | if(IS_WIN) 1254 | { 1255 | local $\ = undef; 1256 | 1257 | my $attr = $main::console->Attr(); 1258 | $main::console->Attr($log_colors{$type}->{win}); 1259 | 1260 | print '['.$type.']'; 1261 | 1262 | $main::console->Attr($attr); 1263 | $msg = ' ' . $msg; 1264 | } 1265 | else 1266 | { 1267 | $msg = Term::ANSIColor::colored('['.$type.']', $log_colors{$type}->{nix}) . ' ' . $msg; 1268 | } 1269 | # Actual terminal width detection? 1270 | $msg = sprintf('%-80s', $msg); 1271 | 1272 | my $out = $type eq ERROR ? *STDERR : *STDOUT; 1273 | print $out $msg; 1274 | } 1275 | 1276 | sub progress 1277 | { 1278 | my ($data, undef, undef) = @_; 1279 | 1280 | $whole_file .= $data; 1281 | print progress_bar(length($whole_file), $total_size); 1282 | } 1283 | 1284 | sub progress_bar 1285 | { 1286 | my ($got, $total, $width, $char) = @_; 1287 | 1288 | $width ||= 25; $char ||= '='; 1289 | my $num_width = length $total; 1290 | sprintf "|%-${width}s| Got %${num_width}s bytes of %s (%.2f%%)\r", 1291 | $char x (($width-1) * $got / $total). '>', 1292 | $got, $total, 100 * $got / +$total; 1293 | } 1294 | 1295 | sub read_file 1296 | { 1297 | my $filename = shift; 1298 | 1299 | if(open(my $fh, '<', $filename)) 1300 | { 1301 | binmode $fh; 1302 | chomp(my @lines = <$fh>); 1303 | close $fh; 1304 | 1305 | # Should I just drop this stuff and demand only utf8? 1306 | my $blob = join '', @lines; 1307 | my $decoder = Encode::Guess->guess($blob, 'utf8'); 1308 | $decoder = Encode::Guess->guess($blob, 'cp1251') unless ref $decoder; 1309 | 1310 | if(!ref $decoder) 1311 | { 1312 | info(ERROR, 'Can\'t detect ' . $filename . ' internal encoding'); 1313 | return; 1314 | } 1315 | 1316 | @lines = map($decoder->decode($_), @lines); 1317 | 1318 | return @lines; 1319 | } 1320 | 1321 | info(ERROR, 'Failed to open file ' . $opt{ignore}); 1322 | 1323 | return; 1324 | } 1325 | 1326 | sub log_response 1327 | { 1328 | my $response = shift; 1329 | return if !$opt{debug}; 1330 | 1331 | my $log_filename = RESPONSE_LOG_PREFIX . time; 1332 | if(open(my $fh, '>', $log_filename)) 1333 | { 1334 | binmode $fh; 1335 | print $fh $response->as_string; 1336 | close $fh; 1337 | 1338 | info(DEBUG, 'Response stored at ' . $log_filename); 1339 | } 1340 | else 1341 | { 1342 | info(DEBUG, 'Failed to store response stored at ' . $log_filename); 1343 | } 1344 | } 1345 | 1346 | sub print_debug_info 1347 | { 1348 | info(DEBUG, 'Yandex Music Downloader v' . VERSION . NL . NL); 1349 | info(DEBUG, 'OS: ' . $^O . '; Path: ' . $^X . '; Version: ' . $^V); 1350 | 1351 | info(DEBUG, 'Cookie: ' . $opt{cookie}) if $opt{cookie}; 1352 | info(DEBUG, 'Auth: ' . $opt{auth}) if $opt{auth}; 1353 | } 1354 | -------------------------------------------------------------------------------- /usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaimi-io/yandex-music-download/9de8e34519aaf9018ee7bf95ce1e7730df0018dd/usage.gif --------------------------------------------------------------------------------