Repository: kaimi-io/yandex-music-download Branch: master Commit: 8109730a60f4 Files: 7 Total size: 40.8 KB Directory structure: gitextract_ymafb784/ ├── .github/ │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── flake.nix └── src/ └── ya.pl ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Docker Image CI on: workflow_dispatch: # Manual only jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Docker Setup Buildx uses: docker/setup-buildx-action@v2.7.0 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v4 with: push: true platforms: linux/arm/v7,linux/arm/v6,linux/arm64/v8,linux/amd64,linux/386 tags: ka1mi/yandex-music-downloader:latest,ka1mi/yandex-music-downloader:${{github.ref_name}} ================================================ FILE: .gitignore ================================================ # Swap [._]*.s[a-v][a-z] [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # nix build result # Session Session.vim Sessionx.vim # Temporary .netrwhist *~ # Auto-generated tag files tags # Persistent undo [._]*.un~ # .omx/ ================================================ FILE: Dockerfile ================================================ FROM alpine:latest ENV LANG=en_US.UTF-8 LC_ALL=C.UTF-8 LANGUAGE=en_US.UTF-8 RUN apk --update add perl perl-app-cpanminus make unzip RUN apk add perl-libwww perl-lwp-protocol-https perl-http-cookies perl-html-parser perl-getopt-long-descriptive perl-archive-zip \ --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ RUN ["cpanm", "MP3::Tag", "File::Util"] COPY src /src ENTRYPOINT [ "/src/ya.pl" ] ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2014 Kaimi, https://kaimi.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Yandex Music Downloader ===================== [![Telegram](https://img.shields.io/badge/Telegram--lightgrey?logo=telegram&style=social)](https://t.me/kaimi_io) [![Twitter](https://img.shields.io/twitter/follow/kaimi_io?style=social)](https://twitter.com/kaimi_io) ![Yandex Music Downloader usage](https://github.com/kaimi-io/yandex-music-download/blob/master/usage.gif?raw=true) Simple command line Perl script for downloading music from Yandex Music (http://music.yandex.ru). Origin of the script is the following article: https://kaimi.io/2013/11/yandex-music-downloader/. ## Requirements ### Environment * Linux/Windows/MacOS (anything, that runs Perl) * Perl >= 5.12 ### Perl modules * General * Digest::MD5 * File::Copy * File::Spec * File::Temp * [File::Util](https://github.com/tommybutler/file-util) * Getopt::Long::Descriptive * HTML::Entities * HTTP::Cookies * JSON::PP * LWP::Protocol::https * LWP::UserAgent * MP3::Tag * Term::ANSIColor * Mozilla::CA * Windows-only modules * Win32::API * Win32::Console * Win32API::File ## Installation ### Ubuntu / Debian ```bash # Prerequisites sudo apt-get update sudo apt-get -y install perl cpanminus make git 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 cpanm Mozilla::CA # Get a copy and run git clone https://github.com/kaimi-io/yandex-music-download.git cd yandex-music-download/src perl ya.pl -h ``` ### Nix / NixOS ```bash nix shell github:kaimi-io/yandex-music-download ya-music -h ``` ### MacOS 1. Install brew (https://brew.sh/). 2. Run: ```bash brew update brew install perl cpanminus git 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 git clone https://github.com/kaimi-io/yandex-music-download.git cd yandex-music-download/src perl ya.pl -h ``` ### Windows With WSL (Windows Subsystem for Linux) installation will be similar to [Ubuntu / Debian](#ubuntu--debian). Otherwise: 1. Download and install ActiveState Perl (https://www.activestate.com/products/perl/downloads/) or Strawberry Perl (http://strawberryperl.com/). 2. Ensure, that Perl was added to system `PATH` environment variable. 3. From Windows command line run: ```perl -v```. It should output Perl version. If not, refer to your Perl distribution documentation about adding Perl to your `PATH` environment variable. 4. Install required modules (it can be done via PPM if you're using ActiveState Perl): ```bash 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 ``` 5. Download and unpack Yandex Music Downloader (https://github.com/kaimi-io/yandex-music-download/archive/master.zip). 6. Run: ```bash cd yandex-music-download/src perl ya.pl -h ``` ### Docker 1. Install Docker (https://docs.docker.com/get-docker/). 2. Pull image from Docker Hub (https://hub.docker.com/r/ka1mi/yandex-music-downloader): ```bash docker pull ka1mi/yandex-music-downloader:latest ``` 3. Or build it: ```bash git clone https://github.com/kaimi-io/yandex-music-download.git cd yandex-music-download docker build --tag yandex-music-downloader:1.0 . ``` 4. Run: ```bash 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 ``` ## Usage ```bat Yandex Music Downloader v1.5 ya.pl [-adhklpstu] [long options...] -p[=INT] --playlist[=INT] playlist id to download -k[=STR] --kind[=STR] playlist kind (eg. ya-playlist, music-blog, music-partners, etc.) -a[=INT] --album[=INT] album to download -t[=INT] --track[=INT] track to download (album id must be specified) -u[=STR] --url[=STR] download by URL -d[=STR] --dir[=STR] download path (current direcotry will be used by default) --skip-existing skip downloading tracks that already exist on the specified path --proxy STR HTTP-proxy (format: 1.2.3.4:8888) --exclude STR skip tracks specified in file --include STR download only tracks specified in file --delay INT delay between downloads (in seconds) --mobile INT use mobile API --auth STR authorization header for mobile version (OAuth...) --cookie STR authorization cookie for web version (Session_id=...) --bitrate INT bitrate (eg. 64, 128, 192, 320) --pattern STR track naming pattern --path STR path saving pattern Available placeholders: #number, #artist, #title, #album, #year Path pattern will be used in addition to the download path directory Example path pattern: #artist/#album-#year -l --link do not fetch, only print links to the tracks -s --silent do not print informational messages --debug print debug info during work -h --help print usage --include and --exclude options use weak match i.e. ~/$term/ Example: ya.pl -p 123 -k ya-playlist ya.pl -a 123 ya.pl -a 123 -t 321 ya.pl -u https://music.yandex.ru/album/215690 --cookie ... ya.pl -u https://music.yandex.ru/album/215688/track/1710808 --auth ... ya.pl -u https://music.yandex.ru/users/ya.playlist/playlists/1257 --cookie ... © 2013-2023 by Kaimi (https://kaimi.io) ``` ## FAQ ### What is the cause for "[ERROR] Yandex.Music is not available"? 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). ## Contribute 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. ## License Yandex Music Downloader Copyright © 2013-2022 by Kaimi (Sergey Belov) - https://kaimi.io. 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. You should have received a copy of the MIT License along with Yandex Music Downloader. If not, see [MIT License](LICENSE). ================================================ FILE: flake.nix ================================================ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = {self, nixpkgs, flake-utils}: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; yaMusic = pkgs.stdenv.mkDerivation { name = "yandex-download-music"; version = "v1.5"; src = ./.; nativeBuildInputs = [ pkgs.makeWrapper ]; buildInputs = [ pkgs.perl (pkgs.buildEnv { name = "rt-perl-deps"; paths = with pkgs.perlPackages; (requiredPerlModules [ FileUtil MP3Tag GetoptLongDescriptive LWPUserAgent LWPProtocolHttps HTTPCookies MozillaCA ]); }) ]; installPhase = '' mkdir -p $out/bin cp src/ya.pl $out/bin/ya-music # cat src/ya.pl | perl -p -e "s/basename\(__FILE__\)/'ya-music'/g" > $out/bin/ya-music # chmod +x $out/bin/ya-music ''; postFixup = '' # wrapProgram will rename ya-music into .ya-music-wrapped # so replace all __FILE__ calls substituteInPlace $out/bin/ya-music \ --replace "basename(__FILE__)" "'ya-music'" wrapProgram $out/bin/ya-music \ --prefix PERL5LIB : $PERL5LIB ''; }; in { packages.default = yaMusic; apps.default = flake-utils.lib.mkApp { drv = yaMusic; }; } ); } ================================================ FILE: src/ya.pl ================================================ #!/usr/bin/env perl use utf8; use strict; use warnings; use Encode qw/from_to decode/; use Encode::Guess; use File::Basename; use POSIX qw/strftime/; use constant IS_WIN => $^O eq 'MSWin32'; use constant { NL => IS_WIN ? "\015\012" : "\012", TIMEOUT => 10, AGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', 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', YANDEX_BASE => 'https://music.yandex.ru', MOBILE_YANDEX_BASE => 'https://api.music.yandex.net', MD5_SALT => 'XGRlBW9FXlekgbPrRHuSiA', 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', MOBILE_DOWNLOAD_INFO_MASK => '/tracks/%d/download-info', DOWNLOAD_PATH_MASK => 'https://%s/get-mp3/%s/%s?track-id=%s&from=service-10-track&similarities-experiment=default', 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=', MOBILE_PLAYLIST_INFO_MASK => '/users/%s/playlists/%d', 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"}', PLAYLIST_FULL_INFO => '/handlers/track-entries.jsx', ALBUM_INFO_MASK => '/api/v2.1/handlers/album/%d?external-domain=music.yandex.ru&overembed=no&__t=%d', MOBILE_ALBUM_INFO_MASK => '/albums/%d/with-tracks', LYRICS_MASK => '/handlers/track.jsx?track=%d:%d&lang=ru&external-domain=music.yandex.ru&overembed=false&ncrnd=%d', FILE_NAME_PATTERN => '#artist - #title', DEFAULT_PERMISSIONS => 755, # For more details refer to 'create_track_entry' function PATTERN_MP3TAGS_RELS => { 'number' => 'TRCK', 'artist' => 'TPE1', 'title' => 'TIT2', 'album' => 'TALB', 'year' => 'TYER', }, FILE_SAVE_EXT => '.mp3', COVER_RESOLUTION => '400x400', GENERIC_COLLECTION => "\x{441}\x{431}\x{43e}\x{440}\x{43d}\x{438}\x{43a}", GENERIC_TITLE => 'Various Artists', URL_ALBUM_REGEX => qr{music\.yandex\.\w+/album/(\d+)}is, URL_TRACK_REGEX => qr{music\.yandex\.\w+/album/(\d+)/track/(\d+)}is, URL_PLAYLIST_REGEX => qr{music\.yandex\.\w+/users/(.+?)/playlists/(\d+)}is, RESPONSE_LOG_PREFIX => 'log_', TEST_URL => 'https://api.music.yandex.net/users/ya.playlist/playlists/1', RENAME_ERRORS_MAX => 5, AUTH_TOKEN_PREFIX => 'OAuth ', COOKIE_PREFIX => 'Session_id=', HQ_BITRATE => '320', DEFAULT_CODEC => 'mp3', PODCAST_TYPE => 'podcast', VERSION => '1.5', COPYRIGHT => '© 2013-2023 by Kaimi (https://kaimi.io)', }; use constant { PLAYLIST_LIKE => 3, PLAYLIST_LIKE_TITLE => 'Мне нравится' }; use constant { DEBUG => 'DEBUG', ERROR => 'ERROR', INFO => 'INFO', OK => 'OK' }; use constant { WIN_UTF8_CODEPAGE => 65001, STD_OUTPUT_HANDLE => 0xFFFFFFF5, FG_BLUE => 1, FG_GREEN => 2, FG_RED => 4, BG_WHITE => 112, SZ_CONSOLE_FONT_INFOEX => 84, FF_DONTCARE => 0 << 4, FW_NORMAL => 400, COORD => 0x000c0000, FONT_NAME => 'Lucida Console' }; my %log_colors = ( &DEBUG => { nix => 'red on_white', win => FG_RED | BG_WHITE }, &ERROR => { nix => 'red', win => FG_RED }, &INFO => { nix => 'blue on_white', win => FG_BLUE | BG_WHITE }, &OK => { nix => 'green on_white', win => FG_GREEN | BG_WHITE } ); my %req_modules = ( NIX => [], WIN => [ qw/Win32::API Win32API::File Win32::Console/ ], 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/ ] ); $\ = NL; my @missing_modules; for my $module(@{$req_modules{ALL}}, IS_WIN ? @{$req_modules{WIN}} : @{$req_modules{NIX}}) { # Suppress MP3::Tag deprecated regex and other warnings eval "local \$SIG{'__WARN__'} = sub {}; require $module"; if($@) { push @missing_modules, $module; } } if(@missing_modules) { print 'Please, install this modules: ' . join ', ', @missing_modules; exit(1); } # PAR issue workaround && different win* approach for Unicode output if(IS_WIN) { binmode STDOUT, ':unix:utf8'; # Unicode (UTF-8) codepage Win32::Console::OutputCP(WIN_UTF8_CODEPAGE); $main::console = Win32::Console->new(STD_OUTPUT_HANDLE); # Set console font with Unicode support (only for Vista+ OS) if((Win32::GetOSVersion())[1] eq 6) { # FaceName size = LF_FACESIZE Win32::API::Struct->typedef ( CONSOLE_FONT_INFOEX => qw { ULONG cbSize; DWORD nFont; DWORD dwFontSize; UINT FontFamily; UINT FontWeight; WCHAR FaceName[32]; } ); Win32::API->Import ( 'kernel32', 'HANDLE WINAPI GetStdHandle(DWORD nStdHandle)' ); Win32::API->Import ( 'kernel32', 'BOOL WINAPI SetCurrentConsoleFontEx(HANDLE hConsoleOutput, BOOL bMaximumWindow, LPCONSOLE_FONT_INFOEX lpConsoleCurrentFontEx)' ); my $font = Win32::API::Struct->new('CONSOLE_FONT_INFOEX'); $font->{cbSize} = SZ_CONSOLE_FONT_INFOEX; $font->{nFont} = 0; $font->{dwFontSize} = COORD; # COORD struct wrap $font->{FontFamily} = FF_DONTCARE; $font->{FontWeight} = FW_NORMAL; $font->{FaceName} = Encode::encode('UTF-16LE', FONT_NAME); SetCurrentConsoleFontEx(GetStdHandle(STD_OUTPUT_HANDLE), 0, $font); } } else { binmode STDOUT, ':encoding(utf8)'; } my ($opt, $usage) = Getopt::Long::Descriptive::describe_options ( 'Yandex Music Downloader v' . VERSION . NL . NL . basename(__FILE__).' %o', ['playlist|p:i', 'playlist id to download'], ['kind|k:s', 'playlist kind (eg. ya-playlist, music-blog, music-partners, etc.)'], ['album|a:i', 'album to download'], ['track|t:i', 'track to download (album id must be specified)'], ['url|u:s', 'download by URL'], ['dir|d:s', 'download path (current direcotry will be used by default)', {default => '.'}], ['skip-existing', 'skip downloading tracks that already exist on the specified path'], ['proxy=s', 'HTTP-proxy (format: 1.2.3.4:8888)'], ['exclude=s', 'skip tracks specified in file'], ['include=s', 'download only tracks specified in file'], ['delay=i', 'delay between downloads (in seconds)', {default => 5}], ['mobile=i', 'use mobile API', {default => 0}], ['auth=s', 'authorization header for mobile version (OAuth...)'], ['cookie=s', 'authorization cookie for web version (Session_id=...)'], ['bitrate=i', 'bitrate (eg. 64, 128, 192, 320)'], ['pattern=s', 'track naming pattern', {default => FILE_NAME_PATTERN}], ['path=s', 'path saving pattern', {default => ''}], [], ['Available placeholders: #number, #artist, #title, #album, #year'], [], ['Path pattern will be used in addition to the download path directory'], [], ['Example path pattern: #artist/#album-#year'], [], ['link|l', 'do not fetch, only print links to the tracks'], ['silent|s', 'do not print informational messages'], ['debug', 'print debug info during work'], ['help|h', 'print usage'], [], ['--include and --exclude options use weak match i.e. ~/$term/'], [], ['Example: '], [basename(__FILE__) . ' -p 123 -k ya-playlist'], [basename(__FILE__) . ' -a 123'], [basename(__FILE__) . ' -a 123 -t 321'], [basename(__FILE__) . ' -u https://music.yandex.ru/album/215690 --cookie ...'], [basename(__FILE__) . ' -u https://music.yandex.ru/album/215688/track/1710808 --auth ...'], [basename(__FILE__) . ' -u https://music.yandex.ru/users/ya.playlist/playlists/1257 --cookie ...'], [], [COPYRIGHT] ); # Get a modifiable options copy my %opt = %{$opt}; if( $opt{help} || ( !$opt{url} && !($opt{track} && $opt{album}) && !$opt{album} && !($opt{playlist} && $opt{kind}) ) ) { print $usage->text; exit(0); } if(!$opt{auth} && !$opt{cookie}) { info(ERROR, 'Please, specify either mobile app auth header value (--auth) or web version auth cookie (--cookie)'); info(ERROR, 'It is no longer possible to download full version of tracks without authentication'); exit(1); } if($opt{mobile} && !$opt{auth} && $opt{cookie}) { info(ERROR, 'Please, provide --auth instead of --cookie for Mobile API'); exit(1); } if(!$opt{mobile} && $opt{auth} && !$opt{cookie}) { info(ERROR, 'Please, provide --cookie instead of --auth for Web API'); exit(1); } if($opt{dir} && !-d $opt{dir}) { info(ERROR, 'Please, specify an existing directory'); exit(1); } # Fix for "Writing of ID3v2.4 is not fully supported (prohibited now via `write_v24')" MP3::Tag->config(write_v24 => 1); MP3::Tag->config(id3v23_unsync => 0); MP3::Tag->config(decode_encoding_v2 => 'UTF-8'); my $ua = LWP::UserAgent->new ( agent => $opt{mobile} ? MOBILE_AGENT : AGENT, default_headers => HTTP::Headers->new ( X_Retpath_Y => 1 ), cookie_jar => HTTP::Cookies->new ( hide_cookie2 => 1 ), timeout => TIMEOUT, ssl_opts => { verify_hostname => $opt{debug} ? 0 : 1, SSL_verify_mode => $opt{debug} ? IO::Socket::SSL->SSL_VERIFY_NONE : IO::Socket::SSL->SSL_VERIFY_PEER, }, send_te => 0 ); my $download_ua = LWP::UserAgent->new ( agent => $opt{mobile} ? MOBILE_AGENT : AGENT, timeout => TIMEOUT, ssl_opts => { verify_hostname => $opt{debug} ? 0 : 1, SSL_verify_mode => $opt{debug} ? IO::Socket::SSL->SSL_VERIFY_NONE : IO::Socket::SSL->SSL_VERIFY_PEER, }, send_te => 0 ); # Fix auth token and cookie format if required my $auth_token = ''; if($opt{mobile} && $opt{auth}) { if($opt{auth} !~ /${\(AUTH_TOKEN_PREFIX)}/i) { $auth_token = AUTH_TOKEN_PREFIX; } $auth_token .= $opt{auth}; $ua->default_header(Authorization => $auth_token); } my $cookie = ''; if(!$opt{mobile} && $opt{cookie}) { $cookie = normalize_cookie($opt{cookie}); if(!$cookie) { exit(1); } $ua->default_header(Cookie => $cookie); } my ($whole_file, $total_size); my $json_decoder = JSON::PP->new->utf8->pretty->allow_nonref->allow_singlequote; my @exclude = (); my @include = (); if($opt{debug}) { print_debug_info(); } if($opt{proxy}) { $ua->proxy(['http', 'https'], 'http://' . $opt{proxy} . '/'); $download_ua->proxy(['http', 'https'], 'http://' . $opt{proxy} . '/'); } if($opt{exclude}) { @exclude = read_file($opt{exclude}); } if($opt{include}) { @include = read_file($opt{include}); } if($opt{url}) { if($opt{url} =~ URL_TRACK_REGEX) { $opt{album} = $1; $opt{track} = $2; } elsif($opt{url} =~ URL_ALBUM_REGEX) { $opt{album} = $1; } elsif($opt{url} =~ URL_PLAYLIST_REGEX) { $opt{kind} = $1; $opt{playlist} = $2; } else { info(ERROR, 'Invalid URL format'); } } if($opt{album} || ($opt{playlist} && $opt{kind})) { my @track_list_info; =pod info(INFO, 'Checking Yandex.Music availability'); my $request = $ua->get(TEST_URL); if($request->code != 404) { info(ERROR, 'Yandex.Music is not available'); exit(1); } else { info(OK, 'Yandex.Music is available') } =cut if($opt{album}) { info(INFO, 'Fetching album info: ' . $opt{album}); @track_list_info = get_album_tracks_info($opt{album}); if(scalar @track_list_info > 0 && $opt{track}) { info(INFO, 'Filtering single track: ' . $opt{track} . ' [' . $opt{album} . ']'); @track_list_info = grep ( $_->{track_id} eq $opt{track} , @track_list_info ); } } else { info(INFO, 'Fetching playlist info: ' . $opt{playlist} . ' [' . $opt{kind} . ']'); @track_list_info = get_playlist_tracks_info($opt{playlist}); } if(!@track_list_info) { info(ERROR, 'Can\'t get track list info'); exit(1); } for my $track_info_ref(@track_list_info) { my $skip = 0; for my $title(@exclude) { if($track_info_ref->{title} =~ /\Q$title\E/) { $skip = 1; last; } } if($opt{skip_existing} && track_file_exists($track_info_ref)) { $skip = 1; } if($skip) { info(INFO, 'Skipping: ' . $track_info_ref->{title}); next; } $skip = 1; for my $title(@include) { if($track_info_ref->{title} =~ /\Q$title\E/) { $skip = 0; last; } } if($skip && $opt{include}) { info(INFO, 'Skipping: ' . $track_info_ref->{title}); next; } if(!$track_info_ref->{title}) { info(ERROR, 'Track with non-existent title. Skipping...'); next; } if($opt{link}) { print(get_track_url($track_info_ref)); } else { fetch_track($track_info_ref); if($opt{delay} && $track_info_ref != $track_list_info[-1]) { info(INFO, 'Waiting for ' . $opt{delay} . ' seconds'); sleep $opt{delay}; } } } info(OK, 'Done!'); } if(IS_WIN) { $main::console->Free(); } sub fetch_track { my $track_info_ref = shift; $track_info_ref->{title} =~ s/\s+$//; $track_info_ref->{title} =~ s/[\\\/:"*?<>|]+/-/g; info(INFO, 'Trying to fetch track: '.$track_info_ref->{title}); my $track_url = get_track_url($track_info_ref); if(!$track_url) { info(ERROR, 'Can\'t get track url'); return; } my $file_path = download_track($track_url); if(!$file_path) { info(ERROR, 'Failed to download track'); return; } info(OK, 'Temporary saved track at '.$file_path); fetch_album_cover($track_info_ref->{mp3tags}); fetch_track_lyrics($track_info_ref); if(write_mp3_tags($file_path, $track_info_ref->{mp3tags})) { info(INFO, 'MP3 tags added for ' . $file_path); } else { info(ERROR, 'Failed to add MP3 tags for ' . $file_path); } my $target_path = create_storage_path($track_info_ref); if(!$target_path) { info(ERROR, 'Failed to create: ' . $target_path); return; } $target_path = File::Spec->catfile($target_path, $track_info_ref->{title} . FILE_SAVE_EXT); if(rename_track($file_path, $target_path)) { info(INFO, $file_path . ' -> ' . $target_path); } else { info(ERROR, $file_path . ' -> ' . $target_path); } } sub create_storage_path { my $track_info_ref = shift; my $target_path = get_storage_path($track_info_ref); my $file_util = File::Util->new(); if(!-d $file_util->make_dir($target_path => oct DEFAULT_PERMISSIONS => {if_not_exists => 1})) { return; } return $target_path; } sub track_file_exists { my $track_info_ref = shift; my $target_path = get_storage_path($track_info_ref); $target_path = File::Spec->catfile($target_path, $track_info_ref->{title} . FILE_SAVE_EXT); return -e $target_path; } sub get_storage_path { my $track_info_ref = shift; my $target_path = $opt{dir}; if($opt{path}) { $target_path = File::Spec->catdir($target_path, $track_info_ref->{storage_path}); } return $target_path; } sub download_track { my ($url) = @_; my $request = $download_ua->head($url); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } $whole_file = ''; $total_size = $request->headers->content_length; info(DEBUG, 'File size from header: ' . $total_size); $request = $download_ua->get($url, ':content_cb' => \&progress); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } my ($file_handle, $file_path) = File::Temp::tempfile(DIR => $opt{dir}); return unless $file_handle; binmode $file_handle; # Autoflush file contents select((select($file_handle),$|=1)[0]); { local $\ = undef; print $file_handle $whole_file; } my $disk_data_size = (stat($file_handle))[7]; close $file_handle; if($total_size && $disk_data_size != $total_size) { info(DEBUG, 'Actual file size differs from expected ('.$disk_data_size.'/'.$total_size.')'); } return $file_path; } sub get_track_url { my $track_info_ref = shift; my $album_id = $track_info_ref->{album_id}; my $track_id = $track_info_ref->{track_id}; my $is_hq = ($opt{bitrate} && ($opt{bitrate} eq HQ_BITRATE)) ? 1 : 0; # Get track path information my $request = $ua->get ( $opt{mobile} ? MOBILE_YANDEX_BASE.sprintf(MOBILE_DOWNLOAD_INFO_MASK, $track_id) : YANDEX_BASE.sprintf(DOWNLOAD_INFO_MASK, $track_id, $album_id, time, $is_hq) ); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } my ($json_data) = $request->content; if(!$json_data) { info(DEBUG, 'Can\'t parse JSON blob'); log_response($request); return; } my $json = create_json($json_data); if(!$json) { info(DEBUG, 'Can\'t create json from data'); log_response($request); return; } # Pick specified bitrate or highest available my $url; if($opt{mobile}) { # Sort by available bitrate (highest first) @{$json->{result}} = sort { $b->{bitrateInKbps} <=> $a->{bitrateInKbps} } @{$json->{result}}; my ($idx, $target_idx) = (0, -1); for my $track_info(@{$json->{result}}) { if($track_info->{codec} eq DEFAULT_CODEC) { if($opt{bitrate} && $track_info->{bitrateInKbps} == $opt{bitrate}) { $target_idx = $idx; last; } elsif(!$opt{bitrate}) { $target_idx = $idx; last; } } $idx++; } if($target_idx < 0) { info(DEBUG, 'Can\'t find track with proper format & bitrate'); log_response($request); return; } $url = @{$json->{result}}[$target_idx]->{downloadInfoUrl}; } else { $url = $json->{src}; } $url = 'https:' . $url unless $url =~ /^https:/; $request = $download_ua->get($url); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } # No proper XML parsing cause it will break soon my %fields = ($request->content =~ /<(\w+)>([^<]+?)<\/\w+>/g); my $hash = Digest::MD5::md5_hex(MD5_SALT . substr($fields{path}, 1) . $fields{s}); $url = sprintf(DOWNLOAD_PATH_MASK, $fields{host}, $hash, $fields{ts}.$fields{path}, $track_id); info(DEBUG, 'Track url: ' . $url); return $url; } sub get_album_tracks_info { my $album_id = shift; my $request = $ua->get ( $opt{mobile} ? MOBILE_YANDEX_BASE.sprintf(MOBILE_ALBUM_INFO_MASK, $album_id) : YANDEX_BASE.sprintf(ALBUM_INFO_MASK, $album_id, time) ); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } my ($json_data) = $request->content; if(!$json_data) { info(DEBUG, 'Can\'t parse JSON blob'); log_response($request); return; } my $json = create_json($json_data); if(!$json) { info(DEBUG, 'Can\'t create json from data: ' . $@); log_response($request); return; } # "Rebase" JSON $json = $opt{mobile} ? $json->{'result'} : $json; my $title = $json->{title}; if(!$title) { info(DEBUG, 'Can\'t get album title'); return; } info(INFO, 'Album title: ' . $title); info(INFO, 'Tracks total: ' . $json->{trackCount}); if($opt{mobile} && !$json->{availableForMobile}) { info(ERROR, 'Album is not available via Mobile API'); return; } my @tracks = (); for my $vol(@{$json->{volumes}}) { for my $track(@{$vol}) { if(!$track->{error}) { push @tracks, create_track_entry($track, 0); } } } return @tracks; } sub get_playlist_tracks_info { my $playlist_id = shift; my $request = $ua->get ( $opt{mobile} ? MOBILE_YANDEX_BASE.sprintf(MOBILE_PLAYLIST_INFO_MASK, $opt{kind}, $playlist_id) : YANDEX_BASE.sprintf(PLAYLIST_INFO_MASK, $opt{kind}, $playlist_id) ); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } my ($json_data) = $request->content; if(!$json_data) { info(DEBUG, 'Can\'t parse JSON blob'); log_response($request); return; } my $json = create_json($json_data); if(!$json) { info(DEBUG, 'Can\'t create json from data: ' . $@); log_response($request); return; } my $title = $opt{mobile} ? ( $opt{playlist} == PLAYLIST_LIKE ? PLAYLIST_LIKE_TITLE : $json->{result}->{title} ) : $json->{playlist}->{title}; if(!$title) { info(DEBUG, 'Can\'t get playlist title'); return; } info(INFO, 'Playlist title: ' . $title); info ( INFO, 'Tracks total: ' . ( $opt{mobile} ? $json->{result}->{trackCount} : $json->{playlist}->{trackCount} ) ); my @tracks_info; my $track_number = 1; if(!$opt{mobile} && $json->{playlist}->{trackIds}) { my @playlist_chunks; my $tracks_ref = $json->{playlist}->{trackIds}; my $sign = $json->{authData}->{user}->{sign}; push @playlist_chunks, [splice @{$tracks_ref}, 0, 150] while @{$tracks_ref}; for my $chunk(@playlist_chunks) { $request = $ua->post ( YANDEX_BASE.PLAYLIST_FULL_INFO, { strict => 'true', sign => $sign, lang => 'ru', experiments => PLAYLIST_REQ_PART, entries => join ',', @{$chunk} } ); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } $json = create_json($request->content); if(!$json) { info(DEBUG, 'Can\'t create json from data'); log_response($request); return; } push @tracks_info, map { create_track_entry($_, $track_number++) } grep { !$_->{error} } @{ $json }; } } else { @tracks_info = map { create_track_entry ( $opt{mobile} ? $_->{track} : $_ , $track_number++ ) } grep { !$_->{error} } @ { $opt{mobile} ? $json->{result}->{tracks} : $json->{playlist}->{tracks} }; } return @tracks_info; } sub create_track_entry { my ($track_info, $track_number) = @_; # Better detection algo? my $is_part_of_album = scalar @{$track_info->{albums}} != 0; my $is_various; if ( exists $track_info->{albums}->[0]->{metaType} && $track_info->{albums}->[0]->{metaType} ne PODCAST_TYPE ) { $is_various = scalar @{$track_info->{artists}} > 1 || ($is_part_of_album && $track_info->{albums}->[0]->{artists}->[0]->{name} eq GENERIC_COLLECTION) ; } # TALB - album title; TPE2 - album artist; # APIC - album picture; TYER - year; # TIT2 - song title; TPE1 - song artist; # TCON - track genre; TRCK - track number # USLT - unsychronised lyrics my %mp3_tags = (); # Special case for podcasts if($track_info->{albums}->[0]->{metaType} eq PODCAST_TYPE) { $mp3_tags{TPE1} = $track_info->{albums}->[0]->{title}; } else { $mp3_tags{TPE1} = join ', ', map { $_->{name} } @{$track_info->{artists}}; } $mp3_tags{TIT2} = $track_info->{title}; # No track number info in JSON if fetching from anything but album if($track_number) { $mp3_tags{TRCK} = $track_number; } else { $mp3_tags{TRCK} = $track_info->{albums}->[0]->{trackPosition}->{index}; } # Append track postfix (like remix) if present if(exists $track_info->{version}) { $mp3_tags{TIT2} .= "\x20" . '(' . $track_info->{version} . ')'; } # For deleted tracks if($is_part_of_album) { $mp3_tags{TALB} = $track_info->{albums}->[0]->{title}; if($track_info->{albums}->[0]->{metaType} eq PODCAST_TYPE) { $mp3_tags{TPE2} = $mp3_tags{TALB}; } else { $mp3_tags{TPE2} = $is_various ? GENERIC_TITLE : $track_info->{albums}->[0]->{artists}->[0]->{name}; } # 'Dummy' cover for post-process $mp3_tags{APIC} = $track_info->{albums}->[0]->{coverUri}; $mp3_tags{TYER} = $track_info->{albums}->[0]->{year}; $mp3_tags{TCON} = $track_info->{albums}->[0]->{genre}; } # Substitute placeholders within a track name and a path name my $track_filename = $opt{pattern}; my $storage_path = $opt{path}; while (my ($pattern, $tag_id) = each %{&PATTERN_MP3TAGS_RELS}) { $track_filename =~ s/\#$pattern/$mp3_tags{$tag_id}/gi; $storage_path =~ s/\#$pattern/$mp3_tags{$tag_id}/gi; } return { # Album id album_id => $track_info->{albums}->[0]->{id}, # Track id track_id => $track_info->{id}, # MP3 tags mp3tags => \%mp3_tags, # 'Save As' file name title => $track_filename, # 'Save As' directory storage_path => $storage_path, }; } sub write_mp3_tags { my ($file_path, $mp3tags) = @_; my $mp3 = MP3::Tag->new($file_path); if(!$mp3) { info(DEBUG, 'Can\'t create MP3::Tag object: ' . $@); return; } $mp3->new_tag('ID3v2'); while(my ($frame, $data) = each %{$mp3tags}) { # Skip empty if($data) { info(DEBUG, 'add_frame: ' . $frame . '=' . substr $data, 0, 16); $mp3->{ID3v2}->add_frame ( $frame, ref $data eq ref [] ? @{$data} : $data ); } } $mp3->{ID3v2}->write_tag; $mp3->close(); return 1; } sub fetch_album_cover { my $mp3tags = shift; my $cover_url = $mp3tags->{APIC}; if(!$cover_url) { info(DEBUG, 'Empty cover URL'); return; } # Normalize url $cover_url =~ s/%%/${\(COVER_RESOLUTION)}/; $cover_url = 'https://' . $cover_url; info(DEBUG, 'Cover URL: ' . $cover_url); my $request = $ua->get($cover_url); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); undef $mp3tags->{APIC}; return; } $mp3tags->{APIC} = [chr(0x0), 'image/jpg', chr(0x0), 'Cover (front)', $request->content]; } sub fetch_track_lyrics { my $track_info_ref = shift; my $mp3tags = $track_info_ref->{mp3tags}; my $lyrics_url = YANDEX_BASE.sprintf(LYRICS_MASK, $track_info_ref->{track_id}, $track_info_ref->{album_id}, time); info(DEBUG, 'Lyrics URL: ' . $lyrics_url); my $request = $ua->get($lyrics_url); if(!$request->is_success) { info(DEBUG, 'Request failed'); log_response($request); return; } my ($json_data) = $request->content; if(!$json_data) { info(DEBUG, 'Can\'t parse JSON blob'); log_response($request); return; } my $json = create_json($json_data); if(!$json) { info(DEBUG, 'Can\'t create json from data'); log_response($request); return; } if($json->{lyricsAvailable}) { my $lyrics = $json->{lyric}->[0]->{fullLyrics}; # Encoding flag explanation: $03 UTF-8 [UTF-8] $mp3tags->{USLT} = [3, 'eng', undef, $lyrics]; } } sub rename_track { my ($src_path, $dst_path) = @_; my ($src_fh, $dst_fh, $is_open_success, $errors) = (undef, undef, 1, 0); if(IS_WIN) { # Extend path limit to 32767 $dst_path = '\\\\?\\' . File::Spec->rel2abs($dst_path); } for(;;) { if($errors >= RENAME_ERRORS_MAX) { info(DEBUG, 'File manipulations failed'); last; } if(!$is_open_success) { close $src_fh if $src_fh; close $dst_fh if $dst_fh; unlink $src_path if -e $src_path; last; } $is_open_success = open($src_fh, '<', $src_path); if(!$is_open_success) { info(DEBUG, 'Can\'t open src_path: ' . $src_path); $errors++; redo; } if(IS_WIN) { my $unicode_path = Encode::encode('UTF-16LE', $dst_path); Encode::_utf8_off($unicode_path); $unicode_path .= "\x00\x00"; # GENERIC_WRITE, OPEN_ALWAYS my $native_handle = Win32API::File::CreateFileW($unicode_path, 0x40000000, 0, [], 2, 0, 0); # ERROR_ALREADY_EXISTS if($^E && $^E != 183) { info(DEBUG, 'CreateFileW failed with: ' . $^E); $errors++; redo; } $is_open_success = Win32API::File::OsFHandleOpen($dst_fh = IO::Handle->new(), $native_handle, 'w'); if(!$is_open_success) { info(DEBUG, 'OsFHandleOpen failed with: ' . $!); $errors++; redo; } } else { $is_open_success = open($dst_fh, '>', $dst_path); if(!$is_open_success) { info(DEBUG, 'Can\'t open dst_path: ' . $dst_path); $errors++; redo; } } if(!File::Copy::copy($src_fh, $dst_fh)) { $is_open_success = 0; info(DEBUG, 'File::Copy::copy failed with: ' . $!); $errors++; redo; } close $src_fh; close $dst_fh; unlink $src_path; return 1; } return 0; } sub create_json { my $json_data = shift; my $json; eval { $json = $json_decoder->decode($json_data); }; if($@) { info(DEBUG, 'Error decoding json ' . $@); return; } HTML::Entities::decode_entities($json_data); return $json; } sub info { my ($type, $msg) = @_; if($opt{silent} && $type ne ERROR) { return; } if($type eq DEBUG) { return if !$opt{debug}; # Func, line, msg $msg = (caller(1))[3] . "(" . (caller(0))[2] . "): " . $msg; } if(IS_WIN) { local $\ = undef; my $attr = $main::console->Attr(); $main::console->Attr($log_colors{$type}->{win}); print '['.$type.']'; $main::console->Attr($attr); $msg = ' ' . $msg; } else { $msg = Term::ANSIColor::colored('['.$type.']', $log_colors{$type}->{nix}) . ' ' . $msg; } # Actual terminal width detection? $msg = sprintf('%-80s', $msg); my $out = $type eq ERROR ? *STDERR : *STDOUT; print $out $msg; } sub progress { my ($data, undef, undef) = @_; $whole_file .= $data; print progress_bar(length($whole_file), $total_size); } sub progress_bar { my ($got, $total, $width, $char) = @_; $width ||= 25; $char ||= '='; my $num_width = length $total; sprintf "|%-${width}s| Got %${num_width}s bytes of %s (%.2f%%)\r", $char x (($width-1) * $got / $total). '>', $got, $total, 100 * $got / +$total; } sub read_file { my $filename = shift; if(open(my $fh, '<', $filename)) { binmode $fh; chomp(my @lines = <$fh>); close $fh; # Should I just drop this stuff and demand only utf8? my $blob = join '', @lines; my $decoder = Encode::Guess->guess($blob, 'utf8'); $decoder = Encode::Guess->guess($blob, 'cp1251') unless ref $decoder; if(!ref $decoder) { info(ERROR, 'Can\'t detect ' . $filename . ' internal encoding'); return; } @lines = map($decoder->decode($_), @lines); return @lines; } info(ERROR, 'Failed to open file ' . $opt{ignore}); return; } sub normalize_cookie { my $cookie = shift; my $cookie_prefix = COOKIE_PREFIX; return if !defined $cookie; $cookie =~ s/^\s+|\s+$//g; return if !$cookie; if($cookie =~ /^\Q$cookie_prefix\E[^;\s]+$/i) { return $cookie; } if($cookie !~ /[=;]/) { return COOKIE_PREFIX . $cookie; } if($cookie =~ /(?:^|;\s*)\Q$cookie_prefix\E([^;\s]+)/i) { return $cookie_prefix . $1; } info(ERROR, 'Failed to find Session_id in provided cookie string'); return; } sub log_response { my $response = shift; return if !$opt{debug}; my $log_filename = RESPONSE_LOG_PREFIX . time; if(open(my $fh, '>', $log_filename)) { binmode $fh; print $fh $response->as_string; close $fh; info(DEBUG, 'Response stored at ' . $log_filename); } else { info(DEBUG, 'Failed to store response stored at ' . $log_filename); } } sub print_debug_info { info(DEBUG, 'Yandex Music Downloader v' . VERSION . NL . NL); info(DEBUG, 'OS: ' . $^O . '; Path: ' . $^X . '; Version: ' . $^V); info(DEBUG, 'Cookie: ') if $opt{cookie}; info(DEBUG, 'Auth: ') if $opt{auth}; }