0

Whitespace -> tab. Usage in readme now corresponds with script. Proxy support (forgot to mention in previous commit).

This commit is contained in:
Kaimi
2015-02-16 18:07:58 +03:00
parent 72b0077283
commit fb458756e8
2 changed files with 363 additions and 364 deletions

View File

@@ -8,16 +8,15 @@ Origin of the script is the following article: http://kaimi.ru/2013/11/yandex-mu
```bat ```bat
ya.pl [-adkpt] [long options...] ya.pl [-adkpt] [long options...]
-p --playlist playlist id to download -p --playlist playlist id to download
-k --kind playlist kind (eg. ya-playlist, music-blog, -k --kind playlist kind (eg. ya-playlist, music-blog, music-partners, etc.)
music-partners, etc.) -a --album album to download
-a --album album to download -t --track track to download (album id must be specified)
-t --track track to download (album id must be specified) -d --dir download path (current direcotry will be used by default)
-d --dir download path (current direcotry will be used by --proxy HTTP-proxy (format: 1.2.3.4:8888)
default)
--debug print debug info during work --debug print debug info during work
--help print usage --help print usage
Example: Example:
ya.pl -p 123 -k ya-playlist ya.pl -p 123 -k ya-playlist

610
src/ya.pl
View File

@@ -8,42 +8,42 @@ use YaHash;
use constant IS_WIN => $^O eq 'MSWin32'; use constant IS_WIN => $^O eq 'MSWin32';
use constant use constant
{ {
NL => IS_WIN ? "\015\012" : "\012", NL => IS_WIN ? "\015\012" : "\012",
TARGET_ENC => IS_WIN ? 'cp1251' : 'utf8', TARGET_ENC => IS_WIN ? 'cp1251' : 'utf8',
TIMEOUT => 5, TIMEOUT => 5,
AGENT => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:25.0) Gecko/20100101 Firefox/25.0', AGENT => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:25.0) Gecko/20100101 Firefox/25.0',
YANDEX_BASE => 'http://music.yandex.ru', YANDEX_BASE => 'http://music.yandex.ru',
MUSIC_INFO_REGEX => qr/var\s+Mu\s+=\s+(.+?);\s+<\/script>/is, MUSIC_INFO_REGEX => qr/var\s+Mu\s+=\s+(.+?);\s+<\/script>/is,
DOWNLOAD_INFO_MASK => '/api/v1.5/handlers/api-jsonp.jsx?requestId=2&nc=%d&action=getTrackSrc&p=download-info/%s/2.mp3', DOWNLOAD_INFO_MASK => '/api/v1.5/handlers/api-jsonp.jsx?requestId=2&nc=%d&action=getTrackSrc&p=download-info/%s/2.mp3',
DOWNLOAD_PATH_MASK => 'http://%s/get-mp3/%s/%s?track-id=%s&from=service-10-track&similarities-experiment=default', DOWNLOAD_PATH_MASK => 'http://%s/get-mp3/%s/%s?track-id=%s&from=service-10-track&similarities-experiment=default',
PLAYLIST_INFO_MASK => '/users/%s/playlists/%d', 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_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', PLAYLIST_FULL_INFO => '/handlers/track-entries.jsx',
ALBUM_INFO_MASK => '/album/%d', ALBUM_INFO_MASK => '/album/%d',
FILE_SAVE_EXT => '.mp3', FILE_SAVE_EXT => '.mp3',
ARTIST_TITLE_DELIM => ' - ' ARTIST_TITLE_DELIM => ' - '
}; };
use constant use constant
{ {
DEBUG => 'DEBUG', DEBUG => 'DEBUG',
ERROR => 'ERROR', ERROR => 'ERROR',
INFO => 'INFO', INFO => 'INFO',
OK => 'OK' OK => 'OK'
}; };
my %log_colors = my %log_colors =
( (
&DEBUG => 'red on_white', &DEBUG => 'red on_white',
&ERROR => 'red', &ERROR => 'red',
&INFO => 'blue on_white', &INFO => 'blue on_white',
&OK => 'green on_white' &OK => 'green on_white'
); );
my %req_modules = my %req_modules =
( (
NIX => [], NIX => [],
WIN => [ qw/Win32::Console::ANSI/ ], WIN => [ qw/Win32::Console::ANSI/ ],
ALL => [ qw/JSON::PP Getopt::Long::Descriptive Term::ANSIColor LWP::UserAgent HTTP::Cookies HTML::Entities/ ] ALL => [ qw/JSON::PP Getopt::Long::Descriptive Term::ANSIColor LWP::UserAgent HTTP::Cookies HTML::Entities/ ]
); );
$\ = NL; $\ = NL;
@@ -51,50 +51,50 @@ $\ = NL;
my @missing_modules; my @missing_modules;
for(@{$req_modules{ALL}}, IS_WIN ? @{$req_modules{WIN}} : @{$req_modules{NIX}}) for(@{$req_modules{ALL}}, IS_WIN ? @{$req_modules{WIN}} : @{$req_modules{NIX}})
{ {
eval "require $_"; eval "require $_";
if($@) if($@)
{ {
($_) = $@ =~ /locate (.+?)(?:\.pm)? in \@INC/; ($_) = $@ =~ /locate (.+?)(?:\.pm)? in \@INC/;
$_ =~ s/\//::/g; $_ =~ s/\//::/g;
push @missing_modules, $_; push @missing_modules, $_;
} }
} }
if(@missing_modules) if(@missing_modules)
{ {
print 'Please, install this modules: '.join ', ', @missing_modules; print 'Please, install this modules: '.join ', ', @missing_modules;
exit; exit;
} }
my ($opt, $usage) = Getopt::Long::Descriptive::describe_options my ($opt, $usage) = Getopt::Long::Descriptive::describe_options
( (
basename(__FILE__).' %o', basename(__FILE__).' %o',
['playlist|p:i', 'playlist id to download'], ['playlist|p:i', 'playlist id to download'],
['kind|k:s', 'playlist kind (eg. ya-playlist, music-blog, music-partners, etc.)'], ['kind|k:s', 'playlist kind (eg. ya-playlist, music-blog, music-partners, etc.)'],
['album|a:i', 'album to download'], ['album|a:i', 'album to download'],
['track|t:i', 'track to download (album id must be specified)'], ['track|t:i', 'track to download (album id must be specified)'],
['dir|d:s', 'download path (current direcotry will be used by default)', {default => '.'}], ['dir|d:s', 'download path (current direcotry will be used by default)', {default => '.'}],
['proxy=s', 'HTTP-proxy (format: 1.2.3.4:8888)'], ['proxy=s', 'HTTP-proxy (format: 1.2.3.4:8888)'],
[], [],
['debug', 'print debug info during work'], ['debug', 'print debug info during work'],
['help', 'print usage'], ['help', 'print usage'],
[], [],
['Example: '], ['Example: '],
["\t".basename(__FILE__).' -p 123 -k ya-playlist'], ["\t".basename(__FILE__).' -p 123 -k ya-playlist'],
["\t".basename(__FILE__).' -a 123'], ["\t".basename(__FILE__).' -a 123'],
["\t".basename(__FILE__).' -a 123 -t 321'] ["\t".basename(__FILE__).' -a 123 -t 321']
); );
if( $opt->help || ( !($opt->track && $opt->album) && !$opt->album && !($opt->playlist && $opt->kind) ) ) if( $opt->help || ( !($opt->track && $opt->album) && !$opt->album && !($opt->playlist && $opt->kind) ) )
{ {
print $usage->text; print $usage->text;
exit; exit;
} }
if($opt->dir && !-d $opt->dir) if($opt->dir && !-d $opt->dir)
{ {
info(ERROR, 'Please, specify an existing directory'); info(ERROR, 'Please, specify an existing directory');
exit; exit;
} }
my ($whole_file, $total_size); my ($whole_file, $total_size);
@@ -109,276 +109,276 @@ if($opt->proxy)
if($opt->album || ($opt->playlist && $opt->kind)) if($opt->album || ($opt->playlist && $opt->kind))
{ {
my @track_list_info; my @track_list_info;
if($opt->album) if($opt->album)
{ {
info(INFO, 'Fetching album info: '.$opt->album); info(INFO, 'Fetching album info: '.$opt->album);
@track_list_info = get_album_tracks_info($opt->album); @track_list_info = get_album_tracks_info($opt->album);
if($opt->track) if($opt->track)
{ {
info(INFO, 'Filtering single track: '.$opt->track.' ['.$opt->album.']'); info(INFO, 'Filtering single track: '.$opt->track.' ['.$opt->album.']');
@track_list_info = grep @track_list_info = grep
( (
(split(/\./, $_->{dir}))[1] eq $opt->track (split(/\./, $_->{dir}))[1] eq $opt->track
, ,
@track_list_info @track_list_info
); );
} }
} }
else else
{ {
info(INFO, 'Fetching playlist info: '.$opt->playlist.' ['.$opt->kind.']'); info(INFO, 'Fetching playlist info: '.$opt->playlist.' ['.$opt->kind.']');
@track_list_info = get_playlist_tracks_info($opt->playlist); @track_list_info = get_playlist_tracks_info($opt->playlist);
} }
if(!@track_list_info) if(!@track_list_info)
{ {
info(ERROR, 'Can\'t get track list info'); info(ERROR, 'Can\'t get track list info');
exit; exit;
} }
for my $track_info_ref(@track_list_info) for my $track_info_ref(@track_list_info)
{ {
if(!$track_info_ref->{title}) if(!$track_info_ref->{title})
{ {
info(ERROR, 'Track with non-existent title. Skipping...'); info(ERROR, 'Track with non-existent title. Skipping...');
next; next;
} }
if(!$track_info_ref->{dir}) if(!$track_info_ref->{dir})
{ {
info(ERROR, 'Track with non-existent path (deleted?). Skipping...'); info(ERROR, 'Track with non-existent path (deleted?). Skipping...');
next; next;
} }
fetch_track($track_info_ref); fetch_track($track_info_ref);
} }
} }
sub fetch_track sub fetch_track
{ {
my $track_info_ref = shift; my $track_info_ref = shift;
fix_encoding(\$track_info_ref->{title}); fix_encoding(\$track_info_ref->{title});
$track_info_ref->{title} =~ s/\s+$//; $track_info_ref->{title} =~ s/\s+$//;
$track_info_ref->{title} =~ s/[\\\/:"*?<>|]+/-/g; $track_info_ref->{title} =~ s/[\\\/:"*?<>|]+/-/g;
info(INFO, 'Trying to fetch track: '.$track_info_ref->{title}); info(INFO, 'Trying to fetch track: '.$track_info_ref->{title});
my $track_url = get_track_url($track_info_ref->{dir}); my $track_url = get_track_url($track_info_ref->{dir});
if(!$track_url) if(!$track_url)
{ {
info(ERROR, 'Can\'t get track url'); info(ERROR, 'Can\'t get track url');
return; return;
} }
my $file_path = download_track($track_url, $track_info_ref->{title}); my $file_path = download_track($track_url, $track_info_ref->{title});
if(!$file_path) if(!$file_path)
{ {
info(ERROR, 'Failed to download track'); info(ERROR, 'Failed to download track');
return; return;
} }
info(OK, 'Saved track at '.$file_path); info(OK, 'Saved track at '.$file_path);
} }
sub download_track sub download_track
{ {
my ($url, $title) = @_; my ($url, $title) = @_;
my $request = $ua->head($url); my $request = $ua->head($url);
if(!$request->is_success) if(!$request->is_success)
{ {
info(DEBUG, 'HEAD request failed'); info(DEBUG, 'HEAD request failed');
return; return;
} }
$whole_file = ''; $whole_file = '';
$total_size = $request->headers->content_length; $total_size = $request->headers->content_length;
info(DEBUG, 'File size from header: '.$total_size); info(DEBUG, 'File size from header: '.$total_size);
$request = $ua->get($url, ':content_cb' => \&progress); $request = $ua->get($url, ':content_cb' => \&progress);
if(!$request->is_success) if(!$request->is_success)
{ {
info(DEBUG, 'GET request failed in '.(caller(0))[3]); info(DEBUG, 'GET request failed in '.(caller(0))[3]);
return; return;
} }
my $file_path = $opt->dir.'/'.$title.FILE_SAVE_EXT; my $file_path = $opt->dir.'/'.$title.FILE_SAVE_EXT;
if(open(F, '>', $file_path)) if(open(F, '>', $file_path))
{ {
local $\ = undef; local $\ = undef;
binmode F; binmode F;
print F $whole_file; print F $whole_file;
close F; close F;
my $disk_data_size = -s $file_path; my $disk_data_size = -s $file_path;
if($total_size && $disk_data_size != $total_size) if($total_size && $disk_data_size != $total_size)
{ {
info(DEBUG, 'Actual file size differs from expected ('.$disk_data_size.'/'.$total_size.')'); info(DEBUG, 'Actual file size differs from expected ('.$disk_data_size.'/'.$total_size.')');
} }
return $file_path; return $file_path;
} }
info(DEBUG, 'Failed to open file '.$file_path); info(DEBUG, 'Failed to open file '.$file_path);
return; return;
} }
sub get_track_url sub get_track_url
{ {
my $storage_dir = shift; my $storage_dir = shift;
my $request = $ua->get(YANDEX_BASE.sprintf(DOWNLOAD_INFO_MASK, time, $storage_dir)); my $request = $ua->get(YANDEX_BASE.sprintf(DOWNLOAD_INFO_MASK, time, $storage_dir));
if(!$request->is_success) if(!$request->is_success)
{ {
info(DEBUG, 'Request failed'); info(DEBUG, 'Request failed');
return; return;
} }
my ($json_data) = $request->content; my ($json_data) = $request->content;
if(!$json_data) if(!$json_data)
{ {
info(DEBUG, 'Can\'t parse JSON blob'); info(DEBUG, 'Can\'t parse JSON blob');
return; return;
} }
my $json = create_json($json_data); my $json = create_json($json_data);
if(!$json) if(!$json)
{ {
info(DEBUG, 'Can\'t create json from data'); info(DEBUG, 'Can\'t create json from data');
return; return;
} }
my %fields = my %fields =
( (
host => $json->{host}, host => $json->{host},
path => $json->{path}, path => $json->{path},
ts => $json->{ts}, ts => $json->{ts},
region => $json->{region}, region => $json->{region},
s => $json->{s} s => $json->{s}
); );
my $hash = hash(substr($fields{path}, 1) . $fields{s}); my $hash = hash(substr($fields{path}, 1) . $fields{s});
my $url = sprintf(DOWNLOAD_PATH_MASK, $fields{host}, $hash, $fields{ts}.$fields{path}, (split /\./, $storage_dir)[1]); my $url = sprintf(DOWNLOAD_PATH_MASK, $fields{host}, $hash, $fields{ts}.$fields{path}, (split /\./, $storage_dir)[1]);
info(DEBUG, 'Track url: '.$url); info(DEBUG, 'Track url: '.$url);
return $url; return $url;
} }
sub get_album_tracks_info sub get_album_tracks_info
{ {
my $album_id = shift; my $album_id = shift;
my $request = $ua->get(YANDEX_BASE.sprintf(ALBUM_INFO_MASK, $album_id)); my $request = $ua->get(YANDEX_BASE.sprintf(ALBUM_INFO_MASK, $album_id));
if(!$request->is_success) if(!$request->is_success)
{ {
info(DEBUG, 'Request failed'); info(DEBUG, 'Request failed');
return; return;
} }
my ($json_data) = ($request->as_string =~ MUSIC_INFO_REGEX); my ($json_data) = ($request->as_string =~ MUSIC_INFO_REGEX);
if(!$json_data) if(!$json_data)
{ {
info(DEBUG, 'Can\'t parse JSON blob'); info(DEBUG, 'Can\'t parse JSON blob');
return; return;
} }
my $json = create_json($json_data); my $json = create_json($json_data);
if(!$json) if(!$json)
{ {
info(DEBUG, 'Can\'t create json from data'); info(DEBUG, 'Can\'t create json from data');
return; return;
} }
my $title = $json->{pageData}->{title}; my $title = $json->{pageData}->{title};
if(!$title) if(!$title)
{ {
info(DEBUG, 'Can\'t get album title'); info(DEBUG, 'Can\'t get album title');
return; return;
} }
fix_encoding(\$title); fix_encoding(\$title);
info(INFO, 'Album title: '.$title); info(INFO, 'Album title: '.$title);
info(INFO, 'Tracks total: '. $json->{pageData}->{trackCount}); info(INFO, 'Tracks total: '. $json->{pageData}->{trackCount});
return map return map
{ {
{ {
dir => $_->{storageDir}, dir => $_->{storageDir},
title=> $_->{artists}->[0]->{name} . ARTIST_TITLE_DELIM . $_->{title} title=> $_->{artists}->[0]->{name} . ARTIST_TITLE_DELIM . $_->{title}
} }
} @{ $json->{pageData}->{volumes}->[0] }; } @{ $json->{pageData}->{volumes}->[0] };
} }
sub get_playlist_tracks_info sub get_playlist_tracks_info
{ {
my $playlist_id = shift; my $playlist_id = shift;
my $request = $ua->get(YANDEX_BASE.sprintf(PLAYLIST_INFO_MASK, $opt->kind, $playlist_id)); my $request = $ua->get(YANDEX_BASE.sprintf(PLAYLIST_INFO_MASK, $opt->kind, $playlist_id));
if(!$request->is_success) if(!$request->is_success)
{ {
info(DEBUG, 'Request failed'); info(DEBUG, 'Request failed');
return; return;
} }
my ($json_data) = ($request->as_string =~ MUSIC_INFO_REGEX); my ($json_data) = ($request->as_string =~ MUSIC_INFO_REGEX);
if(!$json_data) if(!$json_data)
{ {
info(DEBUG, 'Can\'t parse JSON blob'); info(DEBUG, 'Can\'t parse JSON blob');
return; return;
} }
my $json = create_json($json_data); my $json = create_json($json_data);
if(!$json) if(!$json)
{ {
info(DEBUG, 'Can\'t create json from data'); info(DEBUG, 'Can\'t create json from data');
return; return;
} }
my $title = $json->{pageData}->{playlist}->{title}; my $title = $json->{pageData}->{playlist}->{title};
if(!$title) if(!$title)
{ {
info(DEBUG, 'Can\'t get playlist title'); info(DEBUG, 'Can\'t get playlist title');
return; return;
} }
fix_encoding(\$title); fix_encoding(\$title);
info(INFO, 'Playlist title: '.$title); info(INFO, 'Playlist title: '.$title);
info(INFO, 'Tracks total: '. $json->{pageData}->{playlist}->{trackCount}); info(INFO, 'Tracks total: '. $json->{pageData}->{playlist}->{trackCount});
my @tracks_info; my @tracks_info;
if($json->{pageData}->{playlist}->{trackIds}) if($json->{pageData}->{playlist}->{trackIds})
{ {
my @playlist_chunks; my @playlist_chunks;
my $tracks_ref = $json->{pageData}->{playlist}->{trackIds}; my $tracks_ref = $json->{pageData}->{playlist}->{trackIds};
my $sign = $json->{authData}->{user}->{sign}; my $sign = $json->{authData}->{user}->{sign};
push @playlist_chunks, [splice @{$tracks_ref}, 0, 150] while @{$tracks_ref}; push @playlist_chunks, [splice @{$tracks_ref}, 0, 150] while @{$tracks_ref};
for my $chunk(@playlist_chunks) for my $chunk(@playlist_chunks)
{ {
$request = $ua->post $request = $ua->post
( (
YANDEX_BASE.PLAYLIST_FULL_INFO, YANDEX_BASE.PLAYLIST_FULL_INFO,
{ {
strict => 'true', strict => 'true',
sign => $sign, sign => $sign,
lang => 'ru', lang => 'ru',
experiments => PLAYLIST_REQ_PART, experiments => PLAYLIST_REQ_PART,
entries => join ',', @{$chunk} entries => join ',', @{$chunk}
} }
); );
if(!$request->is_success) if(!$request->is_success)
@@ -388,11 +388,11 @@ sub get_playlist_tracks_info
} }
$json = create_json($request->content); $json = create_json($request->content);
if(!$json) if(!$json)
{ {
info(DEBUG, 'Can\'t create json from data'); info(DEBUG, 'Can\'t create json from data');
return; return;
} }
push @tracks_info, push @tracks_info,
map map
@@ -403,16 +403,16 @@ sub get_playlist_tracks_info
} }
} @{ $json }; } @{ $json };
} }
} }
else else
{ {
@tracks_info = map @tracks_info = map
{ {
{ {
dir => $_->{storageDir}, dir => $_->{storageDir},
title=> $_->{artists}->[0]->{name} . ARTIST_TITLE_DELIM . $_->{title} title=> $_->{artists}->[0]->{name} . ARTIST_TITLE_DELIM . $_->{title}
} }
} @{ $json->{pageData}->{playlist}->{tracks} }; } @{ $json->{pageData}->{playlist}->{tracks} };
} }
return @tracks_info; return @tracks_info;
@@ -420,63 +420,63 @@ sub get_playlist_tracks_info
sub create_json sub create_json
{ {
my $json_data = shift; my $json_data = shift;
HTML::Entities::decode_entities($json_data); HTML::Entities::decode_entities($json_data);
my $json; my $json;
eval eval
{ {
$json = $json_decoder->decode($json_data); $json = $json_decoder->decode($json_data);
}; };
if($@) if($@)
{ {
info(DEBUG, 'Error decoding json '.$@); info(DEBUG, 'Error decoding json '.$@);
return; return;
} }
return $json; return $json;
} }
sub fix_encoding sub fix_encoding
{ {
my $ref = shift; my $ref = shift;
from_to($$ref, 'unicode', TARGET_ENC); from_to($$ref, 'unicode', TARGET_ENC);
} }
sub info sub info
{ {
my ($type, $msg) = @_; my ($type, $msg) = @_;
if($type eq DEBUG) if($type eq DEBUG)
{ {
return if !$opt->debug; return if !$opt->debug;
# Func, line, msg # Func, line, msg
$msg = (caller(1))[3] . "(" . (caller(0))[2] . "): " . $msg; $msg = (caller(1))[3] . "(" . (caller(0))[2] . "): " . $msg;
} }
# Actual terminal width detection? # Actual terminal width detection?
$msg = Term::ANSIColor::colored('['.$type.']', $log_colors{$type}) . ' ' . $msg; $msg = Term::ANSIColor::colored('['.$type.']', $log_colors{$type}) . ' ' . $msg;
$msg .= ' ' x (80 - length($msg) - length($\)); $msg .= ' ' x (80 - length($msg) - length($\));
print $msg; print $msg;
} }
sub progress sub progress
{ {
my ($data, undef, undef) = @_; my ($data, undef, undef) = @_;
$whole_file .= $data; $whole_file .= $data;
print progress_bar(length($whole_file), $total_size); print progress_bar(length($whole_file), $total_size);
} }
sub progress_bar sub progress_bar
{ {
my ($got, $total, $width, $char) = @_; my ($got, $total, $width, $char) = @_;
$width ||= 25; $char ||= '='; $width ||= 25; $char ||= '=';
my $num_width = length $total; my $num_width = length $total;
sprintf "|%-${width}s| Got %${num_width}s bytes of %s (%.2f%%)\r", sprintf "|%-${width}s| Got %${num_width}s bytes of %s (%.2f%%)\r",
$char x (($width-1) * $got / $total). '>', $char x (($width-1) * $got / $total). '>',
$got, $total, 100 * $got / +$total; $got, $total, 100 * $got / +$total;
} }