exportify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +106 -0
- data/bin/exportify +8 -0
- data/exportify.gemspec +28 -0
- data/lib/exportify/auth.rb +125 -0
- data/lib/exportify/cli.rb +170 -0
- data/lib/exportify/config.rb +28 -0
- data/lib/exportify/downloader.rb +25 -0
- data/lib/exportify/spotify.rb +98 -0
- data/lib/exportify/tagger.rb +27 -0
- data/lib/exportify/version.rb +5 -0
- data/lib/exportify.rb +8 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba4e32c9eef691c03687d84d69a6ec48824d168dd94411ae78a15c9461165bde
|
|
4
|
+
data.tar.gz: cb5c7a23841c1b3717afa071b48d6401814f425caae4e3fe4af08bbe4c01eb29
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d579e82f30b6437fbc89499e153559c6f8b3ab1b9ce05a805a35280bc6386ac1ee4eb16ce1e0f183a9a0cec9e285e00715ab0dd5dcb429fcfc7cf13fbe1c9cba
|
|
7
|
+
data.tar.gz: 28b1fb6b4133f5924285a6e1b14f90d25fd002b6ce0e72b23a393c9c62084547fff452a2936f0a2edd1e0f528324c116a843491f38a1ae53b01ffd11a5bf32f6
|
data/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Exportify
|
|
2
|
+
|
|
3
|
+
Downloads a Spotify playlist as MP3 files with proper ID3 tags (title, artist, album, year, track number, genre).
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby 3.3+
|
|
8
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — `brew install yt-dlp`
|
|
9
|
+
- Python 3 + [mutagen](https://mutagen.readthedocs.io/) — `pip3 install mutagen`
|
|
10
|
+
- A Spotify Developer application
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
1. Crie um app em [developer.spotify.com](https://developer.spotify.com/dashboard).
|
|
15
|
+
2. Adicione `http://127.0.0.1:8888/callback` como Redirect URI nas configurações do app.
|
|
16
|
+
3. Instale as dependências:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bundle install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
4. Execute o setup interativo:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bin/exportify init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
=== Exportify Setup ===
|
|
30
|
+
|
|
31
|
+
Diretório principal [musics]: ~/Music
|
|
32
|
+
Spotify Client ID: <seu client id>
|
|
33
|
+
Spotify Client Secret: ████████
|
|
34
|
+
|
|
35
|
+
Configuração salva em ~/.exportify
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
As credenciais e o diretório ficam salvos em `~/.exportify` (permissão `600`). Para reconfigurar qualquer campo, execute `init` novamente — Enter em branco mantém o valor atual.
|
|
39
|
+
|
|
40
|
+
> **Alternativa:** as variáveis de ambiente `SPOTIFY_CLIENT_ID` e `SPOTIFY_CLIENT_SECRET` têm prioridade sobre o arquivo de configuração, útil em ambientes de CI.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Baixar uma playlist
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bin/exportify https://open.spotify.com/playlist/<playlist_id>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Os arquivos são organizados em subdiretórios pelo nome da playlist:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
musics/
|
|
54
|
+
Rock dos Anos 80/
|
|
55
|
+
Queen - Bohemian Rhapsody.mp3
|
|
56
|
+
David Bowie - Heroes.mp3
|
|
57
|
+
Trap Brasil/
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Faixas que já existem no disco são ignoradas automaticamente. Rodar o comando novamente após adicionar músicas à playlist baixa apenas as novas.
|
|
62
|
+
|
|
63
|
+
### Sincronização bidirecional
|
|
64
|
+
|
|
65
|
+
Para remover do disco as músicas que foram retiradas da playlist:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bin/exportify https://open.spotify.com/playlist/<playlist_id> --sync
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Regravar tags ID3
|
|
72
|
+
|
|
73
|
+
Para atualizar as tags dos arquivos já baixados sem rebaixar:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
bin/exportify https://open.spotify.com/playlist/<playlist_id> --retag
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Desenvolvimento
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bundle exec rake test # testes
|
|
83
|
+
bundle exec rubocop # lint
|
|
84
|
+
bundle exec bundler-audit check --update # vulnerabilidades em dependências
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
O CI roda os três automaticamente a cada push.
|
|
88
|
+
|
|
89
|
+
## Release
|
|
90
|
+
|
|
91
|
+
Para publicar uma nova versão no RubyGems:
|
|
92
|
+
|
|
93
|
+
1. Atualize `lib/exportify/version.rb`
|
|
94
|
+
2. Crie e faça push da tag:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
git tag v1.0.0
|
|
98
|
+
git push --tags
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
O workflow de release dispara automaticamente e publica a gem. Requer o secret `RUBYGEMS_API_KEY` configurado em Settings → Secrets → Actions do repositório.
|
|
102
|
+
|
|
103
|
+
## Notas
|
|
104
|
+
|
|
105
|
+
- O token OAuth é cacheado em `~/.exportify_token.json` e renovado automaticamente quando expira. Se a sessão for revogada, o arquivo é removido e um novo login é solicitado.
|
|
106
|
+
- Playlists de artistas oficiais ou gravadoras podem retornar erro 403 — isso é uma restrição da API do Spotify para apps de terceiros sem acesso estendido.
|
data/bin/exportify
ADDED
data/exportify.gemspec
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/exportify/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'exportify'
|
|
7
|
+
spec.version = Exportify::VERSION
|
|
8
|
+
spec.authors = ['Caio Santos']
|
|
9
|
+
spec.email = ['caiovitor.santos@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Download Spotify playlists as MP3 files with proper ID3 tags'
|
|
12
|
+
spec.description = 'Exportify authenticates with Spotify via OAuth, fetches all tracks ' \
|
|
13
|
+
'from a playlist, downloads each one as an MP3 using yt-dlp, and ' \
|
|
14
|
+
'writes accurate ID3 tags (title, artist, album, year, track number) ' \
|
|
15
|
+
'via mutagen.'
|
|
16
|
+
|
|
17
|
+
spec.required_ruby_version = '>= 3.3'
|
|
18
|
+
|
|
19
|
+
spec.files = Dir['lib/**/*.rb', 'bin/*', 'README.md', 'exportify.gemspec']
|
|
20
|
+
spec.bindir = 'bin'
|
|
21
|
+
spec.executables = ['exportify']
|
|
22
|
+
spec.require_paths = ['lib']
|
|
23
|
+
|
|
24
|
+
spec.add_dependency 'base64', '~> 0.2'
|
|
25
|
+
spec.add_dependency 'webrick', '~> 1.9'
|
|
26
|
+
|
|
27
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
28
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'base64'
|
|
8
|
+
require 'webrick'
|
|
9
|
+
require_relative 'config'
|
|
10
|
+
|
|
11
|
+
module Exportify
|
|
12
|
+
module Auth
|
|
13
|
+
TOKEN_FILE = File.expand_path('~/.exportify_token.json')
|
|
14
|
+
REDIRECT_URI = 'http://127.0.0.1:8888/callback'
|
|
15
|
+
SCOPES = 'playlist-read-private playlist-read-collaborative'
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def save_token(data)
|
|
20
|
+
data['expires_at'] = Time.now.to_i + data['expires_in'].to_i
|
|
21
|
+
File.write(TOKEN_FILE, JSON.generate(data))
|
|
22
|
+
data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load_token
|
|
26
|
+
return nil unless File.exist?(TOKEN_FILE)
|
|
27
|
+
|
|
28
|
+
JSON.parse(File.read(TOKEN_FILE))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def refresh_token(token_data)
|
|
32
|
+
uri = URI('https://accounts.spotify.com/api/token')
|
|
33
|
+
req = Net::HTTP::Post.new(uri)
|
|
34
|
+
req['Authorization'] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
|
|
35
|
+
req.set_form_data(
|
|
36
|
+
'grant_type' => 'refresh_token',
|
|
37
|
+
'refresh_token' => token_data['refresh_token']
|
|
38
|
+
)
|
|
39
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
40
|
+
data = JSON.parse(res.body)
|
|
41
|
+
handle_token_error!(data)
|
|
42
|
+
data['refresh_token'] ||= token_data['refresh_token']
|
|
43
|
+
save_token(data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def exchange_code(code)
|
|
47
|
+
uri = URI('https://accounts.spotify.com/api/token')
|
|
48
|
+
req = Net::HTTP::Post.new(uri)
|
|
49
|
+
req['Authorization'] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
|
|
50
|
+
req.set_form_data(
|
|
51
|
+
'grant_type' => 'authorization_code',
|
|
52
|
+
'code' => code,
|
|
53
|
+
'redirect_uri' => REDIRECT_URI
|
|
54
|
+
)
|
|
55
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
56
|
+
data = JSON.parse(res.body)
|
|
57
|
+
handle_token_error!(data)
|
|
58
|
+
save_token(data)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_token_error!(data)
|
|
62
|
+
return unless data['error']
|
|
63
|
+
|
|
64
|
+
case data['error']
|
|
65
|
+
when 'invalid_client'
|
|
66
|
+
abort 'Erro de autenticação: SPOTIFY_CLIENT_ID ou SPOTIFY_CLIENT_SECRET inválidos.'
|
|
67
|
+
when 'invalid_grant'
|
|
68
|
+
FileUtils.rm_f(TOKEN_FILE)
|
|
69
|
+
abort "Sessão expirada ou revogada. Arquivo de token removido.\nExecute o comando novamente para fazer login."
|
|
70
|
+
else
|
|
71
|
+
abort "Erro ao obter token Spotify: #{data['error']} — #{data['error_description']}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def authorize!
|
|
76
|
+
params = URI.encode_www_form(
|
|
77
|
+
client_id: client_id,
|
|
78
|
+
response_type: 'code',
|
|
79
|
+
redirect_uri: REDIRECT_URI,
|
|
80
|
+
scope: SCOPES
|
|
81
|
+
)
|
|
82
|
+
url = "https://accounts.spotify.com/authorize?#{params}"
|
|
83
|
+
|
|
84
|
+
puts "\nAbrindo navegador para login no Spotify..."
|
|
85
|
+
puts "Se não abrir automaticamente, acesse:\n#{url}\n"
|
|
86
|
+
system("open '#{url}'")
|
|
87
|
+
|
|
88
|
+
code = nil
|
|
89
|
+
server = WEBrick::HTTPServer.new(
|
|
90
|
+
Port: 8888,
|
|
91
|
+
BindAddress: '127.0.0.1',
|
|
92
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
93
|
+
AccessLog: []
|
|
94
|
+
)
|
|
95
|
+
server.mount_proc('/callback') do |req, res|
|
|
96
|
+
code = req.query['code']
|
|
97
|
+
res.body = '<html><body><h2>Login realizado! Pode fechar esta aba.</h2></body></html>'
|
|
98
|
+
res['Content-Type'] = 'text/html'
|
|
99
|
+
server.shutdown
|
|
100
|
+
end
|
|
101
|
+
server.start
|
|
102
|
+
|
|
103
|
+
abort 'Login cancelado' unless code
|
|
104
|
+
exchange_code(code)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def access_token
|
|
108
|
+
token = load_token
|
|
109
|
+
if token.nil?
|
|
110
|
+
token = authorize!
|
|
111
|
+
elsif Time.now.to_i >= token['expires_at'].to_i - 60
|
|
112
|
+
token = refresh_token(token)
|
|
113
|
+
end
|
|
114
|
+
token['access_token']
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def client_id
|
|
118
|
+
ENV.fetch('SPOTIFY_CLIENT_ID', nil) || Config.load['spotify_client_id']
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def client_secret
|
|
122
|
+
ENV.fetch('SPOTIFY_CLIENT_SECRET', nil) || Config.load['spotify_client_secret']
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative 'auth'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'spotify'
|
|
8
|
+
require_relative 'downloader'
|
|
9
|
+
require_relative 'tagger'
|
|
10
|
+
|
|
11
|
+
module Exportify
|
|
12
|
+
module CLI
|
|
13
|
+
DEFAULT_OUTPUT_DIR = 'musics'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(argv)
|
|
18
|
+
return run_init(argv[1]) if argv[0] == 'init'
|
|
19
|
+
|
|
20
|
+
retag = false
|
|
21
|
+
sync = false
|
|
22
|
+
|
|
23
|
+
parser = OptionParser.new do |opts|
|
|
24
|
+
opts.banner = "Usage:\n " \
|
|
25
|
+
"exportify init\n " \
|
|
26
|
+
'exportify <spotify_playlist_url> [--retag] [--sync]'
|
|
27
|
+
opts.on('--retag', 'Regravar tags ID3 nos arquivos existentes') { retag = true }
|
|
28
|
+
opts.on('--sync', 'Remover arquivos locais que não estão mais na playlist') { sync = true }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
parser.parse!(argv)
|
|
32
|
+
playlist_url = argv[0]&.split('?', 2)&.first
|
|
33
|
+
|
|
34
|
+
abort parser.banner unless playlist_url
|
|
35
|
+
|
|
36
|
+
abort 'Credenciais não configuradas. Execute: exportify init' unless Auth.client_id && Auth.client_secret
|
|
37
|
+
|
|
38
|
+
playlist_id = playlist_url.match(%r{playlist/([A-Za-z0-9]+)})&.captures&.first
|
|
39
|
+
abort 'Invalid playlist URL' unless playlist_id
|
|
40
|
+
|
|
41
|
+
puts 'Authenticating with Spotify...'
|
|
42
|
+
token = Auth.access_token
|
|
43
|
+
|
|
44
|
+
puts 'Fetching playlist...'
|
|
45
|
+
name = Spotify.playlist_name(playlist_id, token)
|
|
46
|
+
tracks = Spotify.playlist_tracks(playlist_id, token)
|
|
47
|
+
tracks = Spotify.enrich_with_genres(tracks, token)
|
|
48
|
+
output_dir = File.expand_path(File.join(Config.output_dir, Downloader.sanitize(name)))
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(output_dir)
|
|
51
|
+
|
|
52
|
+
puts "#{tracks.size} tracks found"
|
|
53
|
+
puts "Output: #{output_dir}\n\n"
|
|
54
|
+
|
|
55
|
+
ok = skip = failed = 0
|
|
56
|
+
|
|
57
|
+
tracks.each_with_index do |track, i|
|
|
58
|
+
artist = Downloader.sanitize(track[:artist])
|
|
59
|
+
name = Downloader.sanitize(track[:name])
|
|
60
|
+
filename = "#{artist} - #{name}.mp3"
|
|
61
|
+
filepath = File.join(output_dir, filename)
|
|
62
|
+
|
|
63
|
+
print "[#{i + 1}/#{tracks.size}] #{filename} "
|
|
64
|
+
|
|
65
|
+
if retag
|
|
66
|
+
if File.exist?(filepath)
|
|
67
|
+
Tagger.tag(filepath, track)
|
|
68
|
+
puts '(retagged)'
|
|
69
|
+
ok += 1
|
|
70
|
+
else
|
|
71
|
+
puts '(not found, skipping)'
|
|
72
|
+
skip += 1
|
|
73
|
+
end
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if File.exist?(filepath)
|
|
78
|
+
puts '(already exists, skipping)'
|
|
79
|
+
skip += 1
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts '(downloading...)'
|
|
84
|
+
success = Downloader.download(track, output_dir)
|
|
85
|
+
|
|
86
|
+
if success && File.exist?(filepath)
|
|
87
|
+
Tagger.tag(filepath, track)
|
|
88
|
+
ok += 1
|
|
89
|
+
else
|
|
90
|
+
failed += 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
removed = 0
|
|
95
|
+
|
|
96
|
+
if sync
|
|
97
|
+
expected = tracks.to_set do |track|
|
|
98
|
+
"#{Downloader.sanitize(track[:artist])} - #{Downloader.sanitize(track[:name])}.mp3"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Dir.glob(File.join(output_dir, '*.mp3')).each do |file|
|
|
102
|
+
next if expected.include?(File.basename(file))
|
|
103
|
+
|
|
104
|
+
puts "Removing #{File.basename(file)}"
|
|
105
|
+
File.delete(file)
|
|
106
|
+
removed += 1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if retag
|
|
111
|
+
puts "\nDone: #{ok} retagged, #{skip} not found."
|
|
112
|
+
else
|
|
113
|
+
removed_msg = sync ? ", #{removed} removed" : ''
|
|
114
|
+
puts "\nDone: #{ok} downloaded, #{skip} skipped, #{failed} failed#{removed_msg}."
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def run_init(dir = nil)
|
|
119
|
+
require 'io/console'
|
|
120
|
+
|
|
121
|
+
cfg = Config.load
|
|
122
|
+
tty = open_tty
|
|
123
|
+
|
|
124
|
+
puts '=== Exportify Setup ==='
|
|
125
|
+
puts
|
|
126
|
+
|
|
127
|
+
default_dir = dir || cfg.fetch('output_dir', DEFAULT_OUTPUT_DIR)
|
|
128
|
+
print "Diretório principal [#{default_dir}]: "
|
|
129
|
+
dir_input = tty.gets.chomp
|
|
130
|
+
new_dir = dir_input.empty? ? default_dir : dir_input
|
|
131
|
+
|
|
132
|
+
current_id = cfg['spotify_client_id'] || ''
|
|
133
|
+
id_hint = current_id.empty? ? '' : " [#{current_id[0..7]}...]"
|
|
134
|
+
print "Spotify Client ID#{id_hint}: "
|
|
135
|
+
id_input = tty.gets.chomp
|
|
136
|
+
new_id = id_input.empty? ? current_id : id_input
|
|
137
|
+
|
|
138
|
+
secret_hint = cfg['spotify_client_secret'] ? ' [configurado]' : ''
|
|
139
|
+
print "Spotify Client Secret#{secret_hint}: "
|
|
140
|
+
new_secret = read_secret(tty)
|
|
141
|
+
puts
|
|
142
|
+
new_secret = cfg['spotify_client_secret'] if new_secret.empty?
|
|
143
|
+
|
|
144
|
+
abort 'Client ID não pode ser vazio.' if new_id.empty?
|
|
145
|
+
abort 'Client Secret não pode ser vazio.' if new_secret.to_s.empty?
|
|
146
|
+
|
|
147
|
+
Config.save(
|
|
148
|
+
'output_dir' => File.expand_path(new_dir),
|
|
149
|
+
'spotify_client_id' => new_id,
|
|
150
|
+
'spotify_client_secret' => new_secret
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
puts "\nConfiguração salva em #{Config::CONFIG_PATH}"
|
|
154
|
+
ensure
|
|
155
|
+
tty&.close
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def open_tty
|
|
159
|
+
IO.console || $stdin
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def read_secret(tty)
|
|
163
|
+
if tty.respond_to?(:noecho)
|
|
164
|
+
tty.noecho(&:gets).chomp
|
|
165
|
+
else
|
|
166
|
+
tty.gets.chomp
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Exportify
|
|
6
|
+
module Config
|
|
7
|
+
CONFIG_PATH = File.expand_path('~/.exportify')
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def load
|
|
12
|
+
return {} unless File.exist?(CONFIG_PATH)
|
|
13
|
+
|
|
14
|
+
JSON.parse(File.read(CONFIG_PATH))
|
|
15
|
+
rescue JSON::ParserError
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save(data)
|
|
20
|
+
File.write(CONFIG_PATH, JSON.pretty_generate(load.merge(data)))
|
|
21
|
+
File.chmod(0o600, CONFIG_PATH)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def output_dir
|
|
25
|
+
load['output_dir'] || CLI::DEFAULT_OUTPUT_DIR
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exportify
|
|
4
|
+
module Downloader
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def download(track, output_dir)
|
|
8
|
+
artist = sanitize(track[:artist])
|
|
9
|
+
name = sanitize(track[:name])
|
|
10
|
+
query = "#{track[:raw_name]} #{track[:all_artists]} official audio"
|
|
11
|
+
template = File.join(output_dir, "#{artist} - #{name}.%(ext)s")
|
|
12
|
+
|
|
13
|
+
system(
|
|
14
|
+
'yt-dlp', "ytsearch1:#{query}",
|
|
15
|
+
'--extract-audio', '--audio-format', 'mp3', '--audio-quality', '0',
|
|
16
|
+
'--output', template,
|
|
17
|
+
'--no-playlist', '--quiet', '--no-warnings'
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sanitize(str)
|
|
22
|
+
str.gsub(%r{[/\\:*?"<>|]}, '').strip
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Exportify
|
|
8
|
+
module Spotify
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def playlist_name(playlist_id, token)
|
|
12
|
+
uri = URI("https://api.spotify.com/v1/playlists/#{playlist_id}?fields=name")
|
|
13
|
+
req = Net::HTTP::Get.new(uri)
|
|
14
|
+
req['Authorization'] = "Bearer #{token}"
|
|
15
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
16
|
+
data = JSON.parse(res.body)
|
|
17
|
+
handle_error!(data['error'], 'playlist')
|
|
18
|
+
data['name']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def playlist_tracks(playlist_id, token)
|
|
22
|
+
tracks = []
|
|
23
|
+
url = "https://api.spotify.com/v1/playlists/#{playlist_id}/items?limit=100"
|
|
24
|
+
|
|
25
|
+
while url
|
|
26
|
+
uri = URI(url)
|
|
27
|
+
req = Net::HTTP::Get.new(uri)
|
|
28
|
+
req['Authorization'] = "Bearer #{token}"
|
|
29
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
30
|
+
data = JSON.parse(res.body)
|
|
31
|
+
handle_error!(data['error'], 'tracks')
|
|
32
|
+
|
|
33
|
+
data['items'].each do |item|
|
|
34
|
+
track = item['item']
|
|
35
|
+
next if track.nil? || track['name'].nil?
|
|
36
|
+
|
|
37
|
+
clean_name = track['name']
|
|
38
|
+
.gsub(/\s*[(\[].*?[)\]]/, '')
|
|
39
|
+
.gsub(/\s*-\s*(feat|ft)\.?.*/i, '')
|
|
40
|
+
.strip
|
|
41
|
+
|
|
42
|
+
tracks << {
|
|
43
|
+
artist: track['artists'].first['name'],
|
|
44
|
+
artist_id: track['artists'].first['id'],
|
|
45
|
+
all_artists: track['artists'].map { |a| a['name'] }.join(', '),
|
|
46
|
+
name: clean_name,
|
|
47
|
+
raw_name: track['name'],
|
|
48
|
+
album: track.dig('album', 'name') || '',
|
|
49
|
+
year: (track.dig('album', 'release_date') || '')[0..3],
|
|
50
|
+
track_number: track['track_number'] || 0,
|
|
51
|
+
genre: ''
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
url = data['next']
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
tracks
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_error!(error, context)
|
|
62
|
+
return unless error
|
|
63
|
+
|
|
64
|
+
status = error['status']
|
|
65
|
+
message = error['message']
|
|
66
|
+
|
|
67
|
+
case status
|
|
68
|
+
when 401
|
|
69
|
+
abort "Erro #{status}: token inválido ou expirado. Apague ~/.exportify_token.json e tente novamente."
|
|
70
|
+
when 403
|
|
71
|
+
abort "Erro 403 ao buscar #{context}: acesso negado pelo Spotify.\n" \
|
|
72
|
+
"Isso ocorre em playlists de artistas ou gravadoras com conteúdo protegido.\n" \
|
|
73
|
+
'Tente com uma playlist pessoal ou pública de outro usuário.'
|
|
74
|
+
when 404
|
|
75
|
+
abort 'Erro 404: playlist não encontrada. Verifique se o link está correto.'
|
|
76
|
+
else
|
|
77
|
+
abort "Erro da API Spotify (#{status}): #{message}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enrich_with_genres(tracks, token)
|
|
82
|
+
artist_ids = tracks.map { |t| t[:artist_id] }.uniq.compact
|
|
83
|
+
genres_by_id = {}
|
|
84
|
+
|
|
85
|
+
artist_ids.each do |artist_id|
|
|
86
|
+
uri = URI("https://api.spotify.com/v1/artists/#{artist_id}")
|
|
87
|
+
req = Net::HTTP::Get.new(uri)
|
|
88
|
+
req['Authorization'] = "Bearer #{token}"
|
|
89
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
90
|
+
data = JSON.parse(res.body)
|
|
91
|
+
handle_error!(data['error'], 'artistas')
|
|
92
|
+
genres_by_id[data['id']] = (data['genres'] || []).first || ''
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
tracks.map { |t| t.merge(genre: genres_by_id[t[:artist_id]] || '') }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exportify
|
|
4
|
+
module Tagger
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def tag(filepath, track)
|
|
8
|
+
python = <<~PY
|
|
9
|
+
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, TPE2, TCON, error
|
|
10
|
+
from mutagen.mp3 import MP3
|
|
11
|
+
try:
|
|
12
|
+
tags = ID3(#{filepath.inspect})
|
|
13
|
+
except error:
|
|
14
|
+
tags = ID3()
|
|
15
|
+
tags['TIT2'] = TIT2(encoding=3, text=#{track[:raw_name].inspect})
|
|
16
|
+
tags['TPE1'] = TPE1(encoding=3, text=#{track[:all_artists].inspect})
|
|
17
|
+
tags['TPE2'] = TPE2(encoding=3, text=#{track[:artist].inspect})
|
|
18
|
+
tags['TALB'] = TALB(encoding=3, text=#{track[:album].inspect})
|
|
19
|
+
tags['TDRC'] = TDRC(encoding=3, text=#{track[:year].inspect})
|
|
20
|
+
tags['TRCK'] = TRCK(encoding=3, text=#{track[:track_number].to_s.inspect})
|
|
21
|
+
tags['TCON'] = TCON(encoding=3, text=#{track[:genre].to_s.inspect})
|
|
22
|
+
tags.save(#{filepath.inspect})
|
|
23
|
+
PY
|
|
24
|
+
system('python3', '-c', python)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/exportify.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: exportify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Caio Santos
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: webrick
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.9'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.9'
|
|
41
|
+
description: Exportify authenticates with Spotify via OAuth, fetches all tracks from
|
|
42
|
+
a playlist, downloads each one as an MP3 using yt-dlp, and writes accurate ID3 tags
|
|
43
|
+
(title, artist, album, year, track number) via mutagen.
|
|
44
|
+
email:
|
|
45
|
+
- caiovitor.santos@gmail.com
|
|
46
|
+
executables:
|
|
47
|
+
- exportify
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- README.md
|
|
52
|
+
- bin/exportify
|
|
53
|
+
- exportify.gemspec
|
|
54
|
+
- lib/exportify.rb
|
|
55
|
+
- lib/exportify/auth.rb
|
|
56
|
+
- lib/exportify/cli.rb
|
|
57
|
+
- lib/exportify/config.rb
|
|
58
|
+
- lib/exportify/downloader.rb
|
|
59
|
+
- lib/exportify/spotify.rb
|
|
60
|
+
- lib/exportify/tagger.rb
|
|
61
|
+
- lib/exportify/version.rb
|
|
62
|
+
homepage:
|
|
63
|
+
licenses: []
|
|
64
|
+
metadata:
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.3'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.5.22
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Download Spotify playlists as MP3 files with proper ID3 tags
|
|
85
|
+
test_files: []
|