1303 lines
28 KiB
Perl
Executable File
1303 lines
28 KiB
Perl
Executable File
#!/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 => 5,
|
|
AGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36',
|
|
MOBILE_AGENT => 'Dalvik/10.1.0 (Linux; U; Android 10.0; Google Pixel 4 - 10.0.0 - API 29 - 768x1280 Build/LRX29M)',
|
|
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 => '/handlers/album.jsx?album=%d&lang=ru&external-domain=music.yandex.ru&overembed=false&ncrnd=%d',
|
|
MOBILE_ALBUM_INFO_MASK => '/albums/%d/with-tracks',
|
|
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.4',
|
|
COPYRIGHT => '© 2013-2022 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);
|
|
}
|
|
|
|
MP3::Tag->config('id3v23_unsync', 0);
|
|
# Fix for "Writing of ID3v2.4 is not fully supported (prohibited now via `write_v24')"
|
|
MP3::Tag->config(write_v24 => 1);
|
|
# 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};
|
|
}
|
|
|
|
my $cookie = '';
|
|
if(!$opt{mobile} && $opt{cookie})
|
|
{
|
|
if($opt{cookie} !~ /${\(COOKIE_PREFIX)}/i)
|
|
{
|
|
$cookie = COOKIE_PREFIX;
|
|
}
|
|
$cookie .= $opt{cookie};
|
|
}
|
|
|
|
my ($whole_file, $total_size);
|
|
my $ua = LWP::UserAgent->new
|
|
(
|
|
agent => $opt{mobile} ? MOBILE_AGENT : AGENT,
|
|
default_headers => HTTP::Headers->new
|
|
(
|
|
Authorization => $auth_token,
|
|
X_Retpath_Y => 1,
|
|
Cookie => $cookie
|
|
),
|
|
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,
|
|
}
|
|
);
|
|
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} . '/');
|
|
}
|
|
|
|
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});
|
|
|
|
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 = $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 = $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 = $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
|
|
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 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 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: ' . $opt{cookie}) if $opt{cookie};
|
|
info(DEBUG, 'Auth: ' . $opt{auth}) if $opt{auth};
|
|
}
|