├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── TODO.md ├── bin └── skyjam ├── defs └── skyjam │ ├── music_manager.proto │ └── music_manager │ ├── auth_request.proto │ ├── export_tracks_request.proto │ ├── export_tracks_response.proto │ └── response.proto ├── doc └── rev │ ├── gen_python_proto.sh │ └── rev_py.txt ├── lib ├── skyjam.rb └── skyjam │ ├── client.rb │ ├── library.rb │ ├── music_manager.pb.rb │ ├── music_manager.rb │ ├── music_manager │ ├── auth_request.pb.rb │ ├── export_tracks_request.pb.rb │ ├── export_tracks_response.pb.rb │ └── response.pb.rb │ ├── track.rb │ └── version.rb └── skyjam.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | 4 | /auth.yml 5 | /oauth2.token.yml 6 | /Gemfile.lock 7 | 8 | /tmp 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - '**/*.pb.rb' 4 | 5 | Documentation: 6 | Enabled: false 7 | 8 | FormatString: 9 | Enabled: false 10 | 11 | PerlBackrefs: 12 | Enabled: false 13 | 14 | AndOr: 15 | Enabled: false 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 Loic Nageleisen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the copyright holders nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-skyjam 2 | 3 | Deftly interact with Google Music (a.k.a Skyjam) 4 | 5 | ## Important foreword 6 | 7 | This uses the same *device id* as *Google's Music Manager* (your MAC address). 8 | The reason is that incrementing the MAC is not a globally solving solution 9 | and that you most probably won't need Google's Music Manager afterwards 10 | because it's, well, “lacking” (to say the least). This may apply to the 11 | *Chrome extension* too but I'm not sure so you'd be best not using it (it's 12 | similarly lacking anyway). 13 | 14 | ## On the command line 15 | 16 | ```bash 17 | gem install skyjam # install the gem 18 | skyjam --auth # authenticates with OAuth, do it only once 19 | skyjam ~/Music/Skyjam # download files into the specified directory 20 | ``` 21 | 22 | Existing files will not be overwritten, so that makes 23 | a nice sync/resume solution. Tracks are downloaded atomically, so 24 | you're safe to `^C`. 25 | 26 | ## Inside ruby 27 | 28 | Add 'skyjam' to your `Gemfile` (you *do* use Gemfiles?) 29 | 30 | Here's a sample of what you can do now: 31 | 32 | ```ruby 33 | require 'skyjam' 34 | 35 | # where you want to store your library 36 | path = File.join(ENV['HOME'], 'Music/Skyjam') 37 | 38 | # Interactive authentication, only needed once. 39 | # This performs OAuth and registers the device 40 | # into Google Music services. Tokens are persisted 41 | # in ~/.config/skyjam 42 | Skyjam::Library.auth 43 | 44 | # Connect the library to Google Music services 45 | # This performs an OAuth refresh with persisted 46 | # tokens 47 | lib = Skyjam::Library.connect(path) 48 | 49 | puts lib.tracks.count 50 | 51 | lib.tracks.take(5).each { |t| puts t.title } # metadata is exposed as accessors 52 | 53 | track = lib.tracks.first 54 | track.download # atomically download track into the library 55 | track.download # noop, since now the file exists 56 | track.download(lazy: false) # forces the download 57 | 58 | track.data # returns track audio data from the file (since it's downloaded) 59 | track.data(remote: true) # forces remote data fetching 60 | ``` 61 | 62 | The following snippet also makes for an interesting interactive 63 | REPL to interact with Google Music, wich is a testament to the 64 | clarity aimed at in this project: 65 | 66 | ```ruby 67 | require 'skyjam' 68 | require 'pry' 69 | SkyJam::Library.connect('some/where').pry 70 | ``` 71 | 72 | ## Future features 73 | 74 | Yes, trouble free upload for the quality minded is coming. 75 | 76 | Also, see [TODO](TODO.md). 77 | 78 | ## Goals 79 | 80 | Have a potent tool that doubles as a library and a documentation 81 | of the Google Music API (including ProtoBuf definitions) 82 | 83 | ## References 84 | 85 | * [Simon Weber's Unofficial Google Music API](https://github.com/simon-weber/Unofficial-Google-Music-API/) 86 | * [Google Music protocol reverse engineering effort](http://www.verious.com/code/antimatter15/google-music-protocol/) (disappeared) 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | load 'protobuf/tasks/compile.rake' 2 | 3 | task :compile do 4 | args = %w(skyjam defs lib ruby .pb.rb) 5 | ::Rake::Task['protobuf:compile'].invoke(*args) 6 | end 7 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # To do 2 | 3 | ## Features 4 | 5 | - Library: add some magic to OAuth init + restore 6 | - Client: maybe use HTTParty 7 | - Track#download: yield bytes received (for progress) if block_given? 8 | - Track#data: yield io (from file or response) if block_given? 9 | 10 | ## Fixes 11 | 12 | - bin/skyjam: robust CLI 13 | - Client: clean up the mess and redundancy 14 | - document this stuff 15 | - cleaner exceptions 16 | - specs, with Google Music API mocking 17 | -------------------------------------------------------------------------------- /bin/skyjam: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'skyjam' 4 | require 'rainbow' 5 | require 'rainbow/ext/string' 6 | 7 | if ARGV.size != 1 8 | $stderr.puts('usage: skyjam [--auth | library_path]') 9 | exit(1) 10 | end 11 | 12 | module SkyJam 13 | path = ARGV.last 14 | 15 | if ARGV[0] == '--auth' 16 | path = nil if path == '--auth' 17 | rc = Library.auth(path) ? 0 : 1 18 | exit(rc) 19 | end 20 | 21 | begin 22 | lib = Library.connect(path) 23 | rescue Client::Error => e 24 | $stderr.puts("error: #{e.message}") 25 | exit(1) 26 | end 27 | 28 | begin 29 | success = failed = 0 30 | 31 | lib.tracks.each.with_index do |track, i| 32 | begin 33 | $stdout.write(" %05d #{track.title.gsub("%","%%")}" % i) 34 | $stdout.flush 35 | track.download(lazy: true) 36 | rescue Client::Error 37 | $stdout.write("\r" + 'NOK'.color(:red)) 38 | failed += 1 39 | else 40 | $stdout.write("\r" + ' OK'.color(:green)) 41 | success += 1 42 | end 43 | $stdout.write("\n") 44 | end 45 | ensure 46 | $stdout.write("\n") 47 | $stdout.write("summary: success %s | failed: %s\n" % 48 | [success.to_s.color(:green), 49 | failed.to_s.color(:red)]) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /defs/skyjam/music_manager.proto: -------------------------------------------------------------------------------- 1 | package SkyJam.MusicManager; 2 | -------------------------------------------------------------------------------- /defs/skyjam/music_manager/auth_request.proto: -------------------------------------------------------------------------------- 1 | package SkyJam.MusicManager; 2 | 3 | message AuthRequest { 4 | required string id = 1; 5 | optional string name = 2; 6 | } 7 | -------------------------------------------------------------------------------- /defs/skyjam/music_manager/export_tracks_request.proto: -------------------------------------------------------------------------------- 1 | package SkyJam.MusicManager; 2 | 3 | message ExportTracksRequest { 4 | enum TrackType { 5 | ALL = 1; 6 | STORE = 2; 7 | } 8 | 9 | required string client_id = 2; 10 | optional string continuation_token = 3; 11 | optional TrackType export_type = 4; 12 | optional int64 updated_min = 5; 13 | } 14 | -------------------------------------------------------------------------------- /defs/skyjam/music_manager/export_tracks_response.proto: -------------------------------------------------------------------------------- 1 | package SkyJam.MusicManager; 2 | 3 | message ExportTracksResponse { 4 | enum Status { 5 | OK = 1; 6 | TRANSIENT_ERROR = 2; 7 | MAX_CLIENTS = 3; 8 | CLIENT_AUTH_ERROR = 4; 9 | CLIENT_REG_ERROR = 5; 10 | } 11 | 12 | message TrackInfo { 13 | optional string id = 1; 14 | optional string title = 2; 15 | optional string album = 3; 16 | optional string album_artist = 4; 17 | optional string artist = 5; 18 | optional int32 track_number = 6; 19 | optional int64 track_size = 7; 20 | } 21 | 22 | required Status status = 1; 23 | repeated TrackInfo track_info = 2; 24 | optional string continuation_token = 3; 25 | optional int64 updated_min = 4; 26 | } 27 | -------------------------------------------------------------------------------- /defs/skyjam/music_manager/response.proto: -------------------------------------------------------------------------------- 1 | package SkyJam.MusicManager; 2 | 3 | message Response { 4 | enum Type { 5 | METADATA = 1; 6 | PLAYLIST = 2; 7 | PLAYLIST_ENTRY = 3; 8 | SAMPLE = 4; 9 | JOBS = 5; 10 | AUTH = 6; 11 | CLIENT_STATE = 7; 12 | UPDATE_UPLOAD_STATE = 8; 13 | DELETE_UPLOAD_REQUESTED = 9; 14 | } 15 | 16 | message Status { 17 | enum Code { 18 | OK = 1; 19 | ALREADY_EXISTS = 2; 20 | SOFT_ERROR = 3; 21 | METADATA_TOO_LARGE = 4; 22 | } 23 | 24 | required Code code = 1; 25 | } 26 | 27 | enum AuthStatus { 28 | OK = 8; 29 | MAX_LIMIT_REACHED = 9; 30 | CLIENT_BOUND_TO_OTHER_ACCOUNT = 10; 31 | CLIENT_NOT_AUTHORIZED = 11; 32 | MAX_PER_MACHINE_USERS_EXCEEDED = 12; 33 | CLIENT_PLEASE_RETRY = 13; 34 | NOT_SUBSCRIBED = 14; 35 | INVALID_REQUEST = 15; 36 | } 37 | 38 | optional Type type = 1; 39 | //optional MetadataResponse metadata = 2; 40 | //optional PlaylistResponse playlist = 3; 41 | //optional PlaylistEntryResponse playlist_entry = 4; 42 | //optional SampleResponse sample = 5; 43 | //optional JobsResponse jobs = 7; 44 | //optional ClientStateResponse client_state = 8; 45 | //optional Policy policy = 6; 46 | optional AuthStatus auth_status = 11; 47 | optional bool auth_error = 12; 48 | } 49 | -------------------------------------------------------------------------------- /doc/rev/gen_python_proto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mkdir -p tmp/py 3 | protoc -I=defs defs/skyjam/*.proto defs/skyjam/**/*.proto --python_out=tmp/py 4 | -------------------------------------------------------------------------------- /doc/rev/rev_py.txt: -------------------------------------------------------------------------------- 1 | type/cpp_type: 2 | 3 / 2 => int64 3 | 5 / 1 => int32 4 | 8 / 7 => bool 5 | 9 / 9 => string 6 | 14 / 8 => enum 7 | 8 | label: 9 | 1 => optional 10 | 2 => required 11 | 3 => repeated 12 | -------------------------------------------------------------------------------- /lib/skyjam.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'net/https' 3 | require 'yaml' 4 | require 'json' 5 | require 'oauth2' 6 | require 'protobuf' 7 | 8 | require 'skyjam/version' 9 | require 'skyjam/music_manager' 10 | require 'skyjam/client' 11 | require 'skyjam/track' 12 | require 'skyjam/library' 13 | 14 | module SkyJam 15 | end 16 | -------------------------------------------------------------------------------- /lib/skyjam/client.rb: -------------------------------------------------------------------------------- 1 | module SkyJam 2 | class Client 3 | class Error < StandardError; end 4 | 5 | def initialize 6 | @base_url = 'https://play.google.com/music/' 7 | @service_url = @base_url + 'services/' 8 | @android_url = 'https://android.clients.google.com/upsj/' 9 | end 10 | 11 | ### Simple auth 12 | # login: with app-specific password, obtain auth token 13 | # cookie: with auth token, obtain cross token (xt) 14 | # loadalltracks: with auth token and cross token, obtain list of tracks 15 | 16 | def login 17 | uri = URI('https://www.google.com/accounts/ClientLogin') 18 | 19 | q = { 'service' => 'sj', 20 | 'account_type' => 'GOOGLE', 21 | 'source' => 'ruby-skyjam-%s' % SkyJam::VERSION, 22 | 'Email' => @account, 23 | 'Passwd' => @password } 24 | 25 | http = Net::HTTP.new(uri.host, uri.port) 26 | http.use_ssl = true 27 | 28 | req = Net::HTTP::Post.new(uri.path) 29 | req.set_form_data(q) 30 | res = http.request(req) 31 | 32 | unless res.is_a? Net::HTTPSuccess 33 | fail Error, 'login failed: #{res.code}' 34 | end 35 | 36 | tokens = Hash[*res.body 37 | .split("\n") 38 | .map { |r| r.split('=', 2) } 39 | .flatten] 40 | @sid = tokens['SID'] 41 | @auth = tokens['Auth'] 42 | end 43 | 44 | def cookie 45 | uri = URI(@base_url + 'listen') 46 | 47 | http = Net::HTTP.new(uri.host, uri.port) 48 | http.use_ssl = true 49 | 50 | req = Net::HTTP::Head.new(uri.path) 51 | req['Authorization'] = 'GoogleLogin auth=%s' % @auth 52 | res = http.request(req) 53 | 54 | unless res.is_a? Net::HTTPSuccess 55 | fail Error, 'cookie failed: #{res.code}' 56 | end 57 | 58 | h = res.to_hash['set-cookie'] 59 | .map { |e| e =~ /^xt=([^;]+);/ and $1 } 60 | .compact.first 61 | @cookie = h 62 | end 63 | 64 | ## Web Client API 65 | 66 | def loadalltracks 67 | uri = URI(@service_url + 'loadalltracks') 68 | 69 | http = Net::HTTP.new(uri.host, uri.port) 70 | http.use_ssl = true 71 | 72 | req = Net::HTTP::Post.new(uri.path) 73 | req.set_form_data('u' => 0, 'xt' => @cookie) 74 | req['Authorization'] = 'GoogleLogin auth=%s' % @auth 75 | res = http.request(req) 76 | 77 | unless res.is_a? Net::HTTPSuccess 78 | fail Error, 'loadalltracks failed: #{res.code}' 79 | end 80 | 81 | JSON.parse(res.body) 82 | end 83 | 84 | ## OAuth2 85 | # https://developers.google.com/accounts/docs/OAuth2InstalledApp 86 | # https://code.google.com/apis/console 87 | 88 | def oauth2_access 89 | { client_id: '256941431767.apps.googleusercontent.com', 90 | client_secret: 'oHTZP8zhh7E8wF6NWsiDULhq' } 91 | end 92 | 93 | def oauth2_endpoint 94 | { site: 'https://accounts.google.com', 95 | authorize_url: '/o/oauth2/auth', 96 | token_url: '/o/oauth2/token' } 97 | end 98 | 99 | def oauth2_request 100 | { scope: 'https://www.googleapis.com/auth/musicmanager', 101 | access_type: 'offline', 102 | approval_prompt: 'force', 103 | redirect_uri: 'urn:ietf:wg:oauth:2.0:oob' } 104 | end 105 | 106 | def oauth2_client 107 | @oauth2_client ||= OAuth2::Client.new(oauth2_access[:client_id], 108 | oauth2_access[:client_secret], 109 | oauth2_endpoint) 110 | @oauth2_client 111 | end 112 | 113 | def oauth2_setup 114 | # ask for OOB auth code 115 | puts oauth2_client.auth_code.authorize_url(oauth2_request) 116 | # user gives code 117 | puts 'code: ' 118 | code = $stdin.gets.chomp.strip 119 | # exchange code for access token and refresh token 120 | uri = oauth2_request[:redirect_uri] 121 | access = oauth2_client.auth_code.get_token(code, 122 | redirect_uri: uri, 123 | token_method: :post) 124 | puts 'access: ' + access.token 125 | puts 'refresh: ' + access.refresh_token 126 | # expires_in 127 | # token_type: Bearer 128 | @oauth2_access_token = access 129 | end 130 | 131 | def oauth2_persist(filename) 132 | File.open(filename, 'wb') do |f| 133 | f.write(YAML.dump(refresh_token: @oauth2_access_token.refresh_token)) 134 | end 135 | end 136 | 137 | def oauth2_restore(filename) 138 | token_h = YAML.load(File.read(filename)) 139 | oauth2_login(token_h[:refresh_token]) 140 | end 141 | 142 | def oauth2_login(refresh_token) 143 | @oauth2_access_token = OAuth2::AccessToken 144 | .from_hash(oauth2_client, 145 | refresh_token: refresh_token) 146 | oauth2_refresh_access_token 147 | end 148 | 149 | def oauth2_refresh_access_token 150 | @oauth2_access_token = @oauth2_access_token.refresh! 151 | end 152 | 153 | def oauth2_access_token_expired? 154 | @oauth2_access_token.expired? 155 | end 156 | 157 | def oauth2_authentication_header 158 | @oauth2_access_token.options[:header_format] % @oauth2_access_token.token 159 | end 160 | 161 | ## MusicManager Uploader identification 162 | 163 | def mac_addr 164 | case RUBY_PLATFORM 165 | when /darwin/ 166 | if (m = `ifconfig en0`.match(/ether (\S{17})/)) 167 | m[1].upcase 168 | end 169 | when /linux/ 170 | devices = Dir['/sys/class/net/*/address'].reject { |a| a =~ %r{/lo/} } 171 | dev = devices.first 172 | File.read(dev).chomp.upcase 173 | end 174 | end 175 | 176 | def hostname 177 | `hostname`.chomp.gsub(/\.local$/, '') 178 | end 179 | 180 | def uploader_id 181 | mac_addr.gsub(/\d{2}$/) { |s| '%02X' % s.hex } 182 | end 183 | 184 | def uploader_name 185 | "#{hostname} (ruby-skyjam-#{VERSION})" 186 | end 187 | 188 | def uploader_auth 189 | # {'User-agent': 'Music Manager (1, 0, 55, 7425 HTTPS - Windows)'}' 190 | # uploader_id uploader_name 191 | pb_body = MusicManager::AuthRequest.new 192 | pb_body.id = uploader_id 193 | pb_body.name = uploader_name 194 | 195 | uri = URI(@android_url + 'upauth') 196 | 197 | http = Net::HTTP.new(uri.host, uri.port) 198 | http.use_ssl = true 199 | 200 | req = Net::HTTP::Post.new(uri.path) 201 | req.body = pb_body.serialize_to_string 202 | req['Content-Type'] = 'application/x-google-protobuf' 203 | req['Authorization'] = oauth2_authentication_header 204 | res = http.request(req) 205 | 206 | unless res.is_a? Net::HTTPSuccess 207 | fail Error, 'uploader_auth failed: #{res.code}' 208 | end 209 | 210 | MusicManager::Response.new.parse_from_string(res.body) 211 | end 212 | 213 | ## MusicManager API 214 | 215 | def listtracks(continuation_token: nil) 216 | oauth2_refresh_access_token if oauth2_access_token_expired? 217 | 218 | pb_body = MusicManager::ExportTracksRequest.new 219 | pb_body.client_id = uploader_id 220 | pb_body.export_type = MusicManager::ExportTracksRequest::TrackType::ALL 221 | pb_body.continuation_token = continuation_token unless continuation_token.nil? 222 | 223 | uri = URI('https://music.google.com/music/exportids') 224 | 225 | http = Net::HTTP.new(uri.host, uri.port) 226 | http.use_ssl = true 227 | 228 | req = Net::HTTP::Post.new(uri.path) 229 | req.body = pb_body.serialize_to_string 230 | req['Content-Type'] = 'application/x-google-protobuf' 231 | req['Authorization'] = oauth2_authentication_header 232 | req['X-Device-ID'] = uploader_id 233 | res = http.request(req) 234 | 235 | unless res.is_a? Net::HTTPSuccess 236 | fail Error, 'listtracks failed: #{res.code}' 237 | end 238 | 239 | MusicManager::ExportTracksResponse.new.parse_from_string(res.body) 240 | end 241 | 242 | def download_url(song_id) 243 | uri = URI('https://music.google.com/music/export') 244 | q = { version: 2, songid: song_id } 245 | 246 | http = Net::HTTP.new(uri.host, uri.port) 247 | http.use_ssl = true 248 | 249 | qs = q.map { |k, v| "#{k}=#{v}" }.join('&') 250 | req = Net::HTTP::Get.new(uri.path + '?' + qs) 251 | req['Authorization'] = oauth2_authentication_header 252 | req['X-Device-ID'] = uploader_id 253 | res = http.request(req) 254 | 255 | unless res.is_a? Net::HTTPSuccess 256 | fail Error, 'download_url failed: #{res.code}' 257 | end 258 | 259 | JSON.parse(res.body)['url'] 260 | end 261 | 262 | def download_track(url) 263 | uri = URI(url) 264 | 265 | http = Net::HTTP.new(uri.host, uri.port) 266 | http.use_ssl = true 267 | 268 | req = Net::HTTP::Get.new(uri.path + '?' + uri.query) 269 | req['Authorization'] = oauth2_authentication_header 270 | #req['User-Agent'] = 'Music Manager (1, 0, 55, 7425 HTTPS - Windows)' 271 | req['X-Device-ID'] = uploader_id 272 | res = http.request(req) 273 | 274 | unless res.is_a? Net::HTTPSuccess 275 | fail Error, 'download_track failed: #{res.code}' 276 | end 277 | 278 | res.body 279 | end 280 | 281 | def read_config 282 | YAML.load(File.read('auth.yml')) 283 | end 284 | 285 | def load_config 286 | config = read_config 287 | @account = config['account'] 288 | @password = config['password'] 289 | end 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /lib/skyjam/library.rb: -------------------------------------------------------------------------------- 1 | module SkyJam 2 | class Library 3 | class << self 4 | def auth(path = nil) 5 | config = auth_config(path) || default_auth_config 6 | 7 | client = Client.new 8 | client.oauth2_setup 9 | client.uploader_auth 10 | 11 | FileUtils.mkdir_p(File.dirname(config)) 12 | client.oauth2_persist(config) 13 | end 14 | 15 | def connect(path) 16 | library = new(path) 17 | 18 | config = default_auth_config if File.exist?(default_auth_config) 19 | config = auth_config(path) if File.exist?(auth_config(path)) 20 | 21 | fail Client::Error, 'no auth' if config.nil? 22 | 23 | library.instance_eval do 24 | @client = Client.new 25 | @client.oauth2_restore(config) 26 | end 27 | 28 | library 29 | end 30 | 31 | private 32 | 33 | def default_auth_config 34 | File.join(ENV['HOME'], '.config/skyjam/skyjam.auth.yml') 35 | end 36 | 37 | def auth_config(path) 38 | File.join(path, '.skyjam.auth.yml') unless path.nil? 39 | end 40 | end 41 | 42 | attr_reader :path 43 | 44 | def initialize(path) 45 | @path = path 46 | end 47 | 48 | def tracks 49 | return @tracks unless @tracks.nil? 50 | 51 | @tracks = [] 52 | continuation_token = nil 53 | 54 | loop do 55 | list = client.listtracks(continuation_token: continuation_token) 56 | 57 | continuation_token = list[:continuation_token] 58 | 59 | list[:track_info].each do |info| 60 | track = SkyJam::Track.new(info) 61 | library = self 62 | track.instance_eval { @library = library } 63 | 64 | @tracks << track 65 | end 66 | 67 | break if continuation_token == '' 68 | end 69 | 70 | @tracks 71 | end 72 | 73 | private 74 | 75 | attr_reader :client 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require 'protobuf/message' 5 | 6 | module SkyJam 7 | module MusicManager 8 | end 9 | 10 | end 11 | 12 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager.rb: -------------------------------------------------------------------------------- 1 | require 'skyjam/music_manager.pb.rb' 2 | require 'skyjam/music_manager/export_tracks_request.pb.rb' 3 | require 'skyjam/music_manager/export_tracks_response.pb.rb' 4 | require 'skyjam/music_manager/auth_request.pb.rb' 5 | require 'skyjam/music_manager/response.pb.rb' 6 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager/auth_request.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require 'protobuf/message' 5 | 6 | module SkyJam 7 | module MusicManager 8 | 9 | ## 10 | # Message Classes 11 | # 12 | class AuthRequest < ::Protobuf::Message; end 13 | 14 | 15 | ## 16 | # Message Fields 17 | # 18 | class AuthRequest 19 | required :string, :id, 1 20 | optional :string, :name, 2 21 | end 22 | 23 | end 24 | 25 | end 26 | 27 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager/export_tracks_request.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require 'protobuf/message' 5 | 6 | module SkyJam 7 | module MusicManager 8 | 9 | ## 10 | # Message Classes 11 | # 12 | class ExportTracksRequest < ::Protobuf::Message 13 | class TrackType < ::Protobuf::Enum 14 | define :ALL, 1 15 | define :STORE, 2 16 | end 17 | 18 | end 19 | 20 | 21 | 22 | ## 23 | # Message Fields 24 | # 25 | class ExportTracksRequest 26 | required :string, :client_id, 2 27 | optional :string, :continuation_token, 3 28 | optional ::SkyJam::MusicManager::ExportTracksRequest::TrackType, :export_type, 4 29 | optional :int64, :updated_min, 5 30 | end 31 | 32 | end 33 | 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager/export_tracks_response.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require 'protobuf/message' 5 | 6 | module SkyJam 7 | module MusicManager 8 | 9 | ## 10 | # Message Classes 11 | # 12 | class ExportTracksResponse < ::Protobuf::Message 13 | class Status < ::Protobuf::Enum 14 | define :OK, 1 15 | define :TRANSIENT_ERROR, 2 16 | define :MAX_CLIENTS, 3 17 | define :CLIENT_AUTH_ERROR, 4 18 | define :CLIENT_REG_ERROR, 5 19 | end 20 | 21 | class TrackInfo < ::Protobuf::Message; end 22 | 23 | end 24 | 25 | 26 | 27 | ## 28 | # Message Fields 29 | # 30 | class ExportTracksResponse 31 | class TrackInfo 32 | optional :string, :id, 1 33 | optional :string, :title, 2 34 | optional :string, :album, 3 35 | optional :string, :album_artist, 4 36 | optional :string, :artist, 5 37 | optional :int32, :track_number, 6 38 | optional :int64, :track_size, 7 39 | end 40 | 41 | required ::SkyJam::MusicManager::ExportTracksResponse::Status, :status, 1 42 | repeated ::SkyJam::MusicManager::ExportTracksResponse::TrackInfo, :track_info, 2 43 | optional :string, :continuation_token, 3 44 | optional :int64, :updated_min, 4 45 | end 46 | 47 | end 48 | 49 | end 50 | 51 | -------------------------------------------------------------------------------- /lib/skyjam/music_manager/response.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require 'protobuf/message' 5 | 6 | module SkyJam 7 | module MusicManager 8 | 9 | ## 10 | # Message Classes 11 | # 12 | class Response < ::Protobuf::Message 13 | class Type < ::Protobuf::Enum 14 | define :METADATA, 1 15 | define :PLAYLIST, 2 16 | define :PLAYLIST_ENTRY, 3 17 | define :SAMPLE, 4 18 | define :JOBS, 5 19 | define :AUTH, 6 20 | define :CLIENT_STATE, 7 21 | define :UPDATE_UPLOAD_STATE, 8 22 | define :DELETE_UPLOAD_REQUESTED, 9 23 | end 24 | 25 | class AuthStatus < ::Protobuf::Enum 26 | define :OK, 8 27 | define :MAX_LIMIT_REACHED, 9 28 | define :CLIENT_BOUND_TO_OTHER_ACCOUNT, 10 29 | define :CLIENT_NOT_AUTHORIZED, 11 30 | define :MAX_PER_MACHINE_USERS_EXCEEDED, 12 31 | define :CLIENT_PLEASE_RETRY, 13 32 | define :NOT_SUBSCRIBED, 14 33 | define :INVALID_REQUEST, 15 34 | end 35 | 36 | class Status < ::Protobuf::Message 37 | class Code < ::Protobuf::Enum 38 | define :OK, 1 39 | define :ALREADY_EXISTS, 2 40 | define :SOFT_ERROR, 3 41 | define :METADATA_TOO_LARGE, 4 42 | end 43 | 44 | end 45 | 46 | 47 | end 48 | 49 | 50 | 51 | ## 52 | # Message Fields 53 | # 54 | class Response 55 | class Status 56 | required ::SkyJam::MusicManager::Response::Status::Code, :code, 1 57 | end 58 | 59 | optional ::SkyJam::MusicManager::Response::Type, :type, 1 60 | optional ::SkyJam::MusicManager::Response::AuthStatus, :auth_status, 11 61 | optional :bool, :auth_error, 12 62 | end 63 | 64 | end 65 | 66 | end 67 | 68 | -------------------------------------------------------------------------------- /lib/skyjam/track.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module SkyJam 4 | class Track 5 | module Source 6 | STORE = 1 7 | UPLOAD = 2 8 | MATCH = 6 9 | end 10 | 11 | attr_reader :id, 12 | :title, 13 | :album, 14 | :album_artist, 15 | :artist, 16 | :number, 17 | :size 18 | 19 | def initialize(info) 20 | @id = info[:id] 21 | @title = info[:title] 22 | @album = info[:album] 23 | @album_artist = info[:album_artist] unless info[:album_artist].nil? || info[:album_artist].empty? 24 | @artist = info[:artist] 25 | @number = info[:track_number] 26 | @size = info[:track_size] 27 | end 28 | 29 | def filename 30 | escape_path_component("%02d - #{title.gsub("%","%%")}" % number) << extname 31 | end 32 | 33 | def extname 34 | '.mp3' 35 | end 36 | 37 | def dirname 38 | path_components = [library.path, 39 | escape_path_component(album_artist || artist), 40 | escape_path_component(album)] 41 | File.join(path_components) 42 | end 43 | 44 | def path 45 | File.join(dirname, filename) 46 | end 47 | 48 | def local? 49 | File.exist?(path) 50 | end 51 | 52 | def download(lazy: false) 53 | return if !lazy || (lazy && local?) 54 | 55 | file = Tempfile.new(filename) 56 | begin 57 | file << data(remote: true) 58 | rescue SkyJam::Client::Error 59 | file.close! 60 | raise 61 | else 62 | make_dir 63 | FileUtils.mv(file.path, path) 64 | end 65 | end 66 | 67 | def data(remote: false) 68 | if remote || !local? 69 | url = client.download_url(id) 70 | client.download_track(url) 71 | else 72 | File.binread(path) 73 | end 74 | end 75 | 76 | def upload 77 | fail NotImplementedError 78 | end 79 | 80 | private 81 | 82 | attr_reader :library 83 | 84 | def client 85 | library.send(:client) 86 | end 87 | 88 | def make_dir 89 | FileUtils.mkdir_p(dirname) 90 | end 91 | 92 | def escape_path_component(component) 93 | # OSX: : -> FULLWIDTH COLON (U+FF1A) 94 | # OSX: / -> : (translated as / in Cocoa) 95 | # LINUX: / -> DIVISION SLASH (U+2215) 96 | component = component.dup 97 | 98 | component.gsub!(':', "\uFF1A") 99 | component.gsub!('/', ':') 100 | 101 | component 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/skyjam/version.rb: -------------------------------------------------------------------------------- 1 | module SkyJam 2 | VERSION = '0.5.3' 3 | end 4 | -------------------------------------------------------------------------------- /skyjam.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 2 | 3 | require 'skyjam/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'skyjam' 7 | s.version = SkyJam::VERSION 8 | s.authors = ['Loic Nageleisen'] 9 | s.email = ['loic.nageleisen@gmail.com'] 10 | s.homepage = 'https://github.com/lloeki/ruby-skyjam' 11 | s.summary = 'Google Music API client' 12 | s.description = 'Deftly interact with Google Music (a.k.a Skyjam)' 13 | 14 | s.files = Dir['{bin}/*'] + 15 | Dir['{lib}/**/*'] + 16 | ['LICENSE', 'Rakefile', 'README.md'] 17 | s.executables << 'skyjam' 18 | 19 | s.add_dependency 'oauth2', '~> 0.9' 20 | s.add_dependency 'rainbow' 21 | s.add_dependency 'protobuf', '~> 3.0' 22 | s.add_development_dependency 'pry' 23 | s.add_development_dependency 'rubocop' 24 | s.add_development_dependency 'rspec', '~> 2.14' 25 | s.add_development_dependency 'rake', '~> 10.3' 26 | end 27 | --------------------------------------------------------------------------------