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 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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
+
6
+ require 'exportify'
7
+
8
+ Exportify::CLI.run(ARGV)
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exportify
4
+ VERSION = '1.0.0'
5
+ end
data/lib/exportify.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'exportify/version'
4
+ require_relative 'exportify/auth'
5
+ require_relative 'exportify/spotify'
6
+ require_relative 'exportify/downloader'
7
+ require_relative 'exportify/tagger'
8
+ require_relative 'exportify/cli'
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: []