shazamio-rb 0.1.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: 169b00c56b72334bc204042b05232115f40e0cb57771797cfdcdabfeee8e389d
4
+ data.tar.gz: 644afd665ddfa4414013de25f01455cca6c37e871f9cb3f3a560e86fee9b6900
5
+ SHA512:
6
+ metadata.gz: e54e444b58c06cbdfdb3eb01d72763aa6fa8b85e4fbe7699ebb366059b7a0e8682c26b5b24e5b888243e76f70a60696f5a78ae08c9611f9303d533925d583d27
7
+ data.tar.gz: 3ac1c8b7ac66f09d8ec5a958e41a42b2291f154ff5dfe1f1dc8e27e797ad7a301f71a20d86ce81f24f94d5dce7a51c61e432e91d2a8f08dfd6727b4564989d27
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # shazamio-rb
2
+
3
+ A Ruby port of [ShazamIO](https://github.com/shazamio/ShazamIO): a client for
4
+ the reverse engineered Shazam API. Recognize songs from an audio file,
5
+ search tracks/artists, and fetch track metadata.
6
+
7
+ ## Install
8
+
9
+ Copy this directory into your project, or build the gem locally:
10
+
11
+ ```bash
12
+ gem build shazamio.gemspec
13
+ gem install ./shazamio-rb-0.1.0.gem
14
+ ```
15
+
16
+ `shazamio-rb` is stdlib-only (`net/http`, `json`, `zlib`, `base64`, ...) —
17
+ no gems are required at runtime. `recognize` additionally shells out to the
18
+ `ffmpeg` binary to decode audio files, exactly like the Python project does
19
+ under the hood via `pydub`.
20
+
21
+ ## Usage
22
+
23
+ ```ruby
24
+ require "shazamio"
25
+
26
+ shazam = Shazamio::Shazam.new
27
+
28
+ # Recognize a song from a file (mp3, wav, ogg, ... anything ffmpeg reads).
29
+ # Confirmed working end-to-end: recognized a real track against the live API.
30
+ result = shazam.recognize("song.mp3")
31
+ puts result.dig("track", "title")
32
+
33
+ # Track info
34
+ shazam.track_about(552_406_075)
35
+
36
+ # Search
37
+ shazam.search_track("Alan Walker", limit: 5)
38
+ shazam.search_artist("Alan Walker", limit: 5)
39
+
40
+ # Related tracks / listening counters
41
+ shazam.related_tracks(559_284_007, limit: 10)
42
+ shazam.listening_counter(559_284_007)
43
+ shazam.listening_counter_many([559_284_007, 552_406_075])
44
+ ```
45
+
46
+ Every method returns the raw Shazam JSON response as a Ruby `Hash`. For the
47
+ handful of fields most people actually want off a track payload, there's a
48
+ small helper:
49
+
50
+ ```ruby
51
+ track_hash = shazam.track_about(552_406_075)
52
+ info = Shazamio::Serialize.track(track_hash["track"] || track_hash)
53
+ info.title #=> "Ale Jazz"
54
+ info.spotify_url
55
+ info.apple_music_url
56
+ info.raw # the untouched Hash, for anything not mapped above
57
+ ```
58
+
59
+ More runnable examples live in [`examples/`](examples).
60
+
61
+ ## Not included (broken upstream, not a porting bug)
62
+
63
+ The Python project also has `artist_about`, `artist_albums`, `search_album`,
64
+ and every "top tracks" chart method (`top_world_tracks`,
65
+ `top_country_tracks`, `top_city_tracks`, `top_world_genre_tracks`,
66
+ `top_country_genre_tracks`). All of these hit URLs under
67
+ `https://www.shazam.com/services/amapi/v1/catalog/...`.
68
+
69
+ That whole path is currently returning **405 Method Not Allowed** for GET
70
+ requests on Shazam's own servers — confirmed by a report of the exact same
71
+ 405 on the exact same path, hit by Shazam's own web app in a browser (not
72
+ by any third-party client): see [this Apple Community
73
+ thread](https://discussions.apple.com/thread/256185871).
74
+
75
+
76
+ ## Porting notes
77
+
78
+ A few things a straight Python→Ruby translation can't carry over 1:1:
79
+
80
+ - **No async runtime.** The Python client is built on `asyncio`/`aiohttp`.
81
+ Ruby's standard library has no equivalent, so `HTTPClient` is synchronous
82
+ (`Net::HTTP` under the hood, with the same exponential-backoff retry on
83
+ 429/500/502/503/504 as the Python `aiohttp_retry.ExponentialRetry`, and
84
+ redirect-following, which `Net::HTTP` doesn't do by default but `aiohttp`
85
+ does). Wrap calls in threads, or use the `async`/`async-http` gems, if you
86
+ need concurrency.
87
+ - **No Rust extension for recognition.** Python's `recognize()` calls into a
88
+ compiled Rust library (`shazamio_core`) for speed; there's no Ruby
89
+ equivalent available offline. This port always uses the algorithm from the
90
+ Python project's *pure-Python* fallback (`algorithm.py`, what backs the
91
+ deprecated `recognize_song`), translated line-for-line into
92
+ `Shazamio::SignatureGenerator`. It produces the same signatures, just
93
+ slower — expect a handful of seconds of Ruby-side CPU work per ~10s audio
94
+ clip, since there's no NumPy/FFTW here either (see next point). It has
95
+ been confirmed to work end-to-end against the live API.
96
+ - **Pure-Ruby FFT.** `Shazamio::FFT` is a small iterative Cooley-Tukey FFT
97
+ (power-of-two sizes only, which is all the algorithm ever needs). It's
98
+ plain Ruby, not vectorized/SIMD like NumPy. If you process a lot of audio,
99
+ dropping in a binding to FFTW (e.g. a `fftw3` gem) or `Numo::NArray`
100
+ instead is a drop-in swap: only `FFT.rfft` needs to change.
101
+ - **Audio decoding via `ffmpeg` directly**, instead of `pydub` (which itself
102
+ just wraps `ffmpeg`). Same external dependency, one less layer. `recognize`
103
+ treats a `String` argument as a file path; to pass raw audio bytes instead
104
+ of a path, wrap them (e.g. `StringIO.new(bytes)`) so there's no ambiguity
105
+ between "a path" and "some bytes that happen to be a String".
106
+ - **Schemas simplified.** The Python project maps every response onto a deep
107
+ tree of Pydantic models (`shazamio/schemas/**`, thousands of lines).
108
+ This port keeps the ergonomics people actually use — `Shazamio::Serialize`
109
+ exposes the common fields as plain Ruby `Struct`s — but doesn't reproduce
110
+ the full model tree. `result.raw` (or just using the returned `Hash`
111
+ directly) always gives you everything Shazam sent back.
112
+
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "enums"
4
+ require_relative "signature"
5
+ require_relative "fft"
6
+
7
+ module Shazamio
8
+ # Fixed-size circular buffer, ported from algorithm.py's RingBuffer(list).
9
+ class RingBuffer
10
+ attr_reader :buffer_size
11
+ attr_accessor :position, :num_written
12
+
13
+ def initialize(buffer_size, default_value: nil)
14
+ @buffer_size = buffer_size
15
+ @data = Array.new(buffer_size) { default_value.nil? ? nil : default_value.dup }
16
+ @position = 0
17
+ @num_written = 0
18
+ end
19
+
20
+ def [](index)
21
+ @data[index % @buffer_size]
22
+ end
23
+
24
+ def []=(index, value)
25
+ @data[index % @buffer_size] = value
26
+ end
27
+
28
+ def append(value)
29
+ @data[@position] = value
30
+ @position = (@position + 1) % @buffer_size
31
+ @num_written += 1
32
+ end
33
+ end
34
+
35
+ # Precomputed Hanning window (matches numpy's np.hanning(2050)[1:-1]).
36
+ def self.hanning(size)
37
+ return [] if size <= 0
38
+ return [1.0] if size == 1
39
+
40
+ Array.new(size) { |n| 0.5 - 0.5 * Math.cos(2 * Math::PI * n / (size - 1)) }
41
+ end
42
+
43
+ HANNING_MATRIX = hanning(2050)[1..-2].freeze # 2048 values, trims the leading/trailing zero
44
+
45
+ # Pure-Ruby reimplementation of Shazam's signature (audio fingerprint)
46
+ # generator. This is the algorithm the Python project calls "legacy" /
47
+ # pure-Python, used by the deprecated `recognize_song`; the newer
48
+ # `recognize` in Python instead calls into a compiled Rust extension
49
+ # (`shazamio_core`) for speed. There is no Ruby equivalent of that native
50
+ # extension here, so this pure-Ruby path (translated line-for-line from
51
+ # algorithm.py) is the only recognizer available in this port. It is
52
+ # correct but noticeably slower than the Rust version on long clips.
53
+ class SignatureGenerator
54
+ MAX_PEAKS = 255
55
+
56
+ attr_accessor :max_time_seconds, :samples_processed
57
+ attr_reader :next_signature
58
+
59
+ def initialize
60
+ @input_pending_processing = []
61
+ @samples_processed = 0
62
+
63
+ @ring_buffer_of_samples = RingBuffer.new(2048, default_value: 0)
64
+ @fft_outputs = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
65
+ @spread_fft_output = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
66
+
67
+ @max_time_seconds = 3.1
68
+
69
+ @next_signature = DecodedMessage.new
70
+ @next_signature.sample_rate_hz = 16_000
71
+ @next_signature.number_samples = 0
72
+ @next_signature.frequency_band_to_sound_peaks = {}
73
+ end
74
+
75
+ # samples: Array of signed 16-bit, 16kHz mono PCM integers.
76
+ def feed_input(samples)
77
+ @input_pending_processing.concat(samples)
78
+ end
79
+
80
+ def get_next_signature
81
+ return nil if @input_pending_processing.length - @samples_processed < 128
82
+
83
+ while (@input_pending_processing.length - @samples_processed >= 128) &&
84
+ ((@next_signature.number_samples.to_f / @next_signature.sample_rate_hz < @max_time_seconds) ||
85
+ (@next_signature.frequency_band_to_sound_peaks.values.sum(&:length) < MAX_PEAKS))
86
+ chunk = @input_pending_processing[@samples_processed, 128]
87
+ process_input(chunk)
88
+ @samples_processed += 128
89
+ end
90
+
91
+ @ring_buffer_of_samples = RingBuffer.new(2048, default_value: 0)
92
+ @fft_outputs = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
93
+ @spread_fft_output = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
94
+
95
+ @next_signature
96
+ end
97
+
98
+ private
99
+
100
+ def process_input(samples)
101
+ @next_signature.number_samples += samples.length
102
+ position = 0
103
+ while position < samples.length
104
+ do_fft(samples[position, 128])
105
+ do_peak_spreading_and_recognition
106
+ position += 128
107
+ end
108
+ end
109
+
110
+ def do_fft(batch_of_128)
111
+ start = @ring_buffer_of_samples.position
112
+ batch_of_128.each_with_index { |v, i| @ring_buffer_of_samples[start + i] = v }
113
+ @ring_buffer_of_samples.position = (start + batch_of_128.length) % 2048
114
+ @ring_buffer_of_samples.num_written += batch_of_128.length
115
+
116
+ pos = @ring_buffer_of_samples.position
117
+ excerpt = (pos...(pos + 2048)).map { |i| @ring_buffer_of_samples[i] }
118
+
119
+ windowed = excerpt.each_with_index.map { |v, i| v * HANNING_MATRIX[i] }
120
+ fft_bins = FFT.rfft(windowed)
121
+
122
+ fft_results = fft_bins.map do |c|
123
+ v = (c.real**2 + c.imaginary**2) / (1 << 17)
124
+ v < 0.0000000001 ? 0.0000000001 : v
125
+ end
126
+
127
+ @fft_outputs.append(fft_results)
128
+ end
129
+
130
+ def do_peak_spreading_and_recognition
131
+ do_peak_spreading
132
+ do_peak_recognition if @spread_fft_output.num_written >= 46
133
+ end
134
+
135
+ def do_peak_spreading
136
+ origin_last_fft = @fft_outputs[@fft_outputs.position - 1]
137
+ n = origin_last_fft.length
138
+
139
+ spread = origin_last_fft.dup
140
+ (0...(n - 3)).each do |i|
141
+ spread[i] = [origin_last_fft[i], origin_last_fft[i + 1], origin_last_fft[i + 2]].max
142
+ end
143
+
144
+ # Cumulative max chained backwards into the last 1/3/6 spread frames,
145
+ # matching the (redundant but equivalent) vstack+max done in Python.
146
+ pos = @spread_fft_output.position
147
+ carry = spread
148
+ [-1, -3, -6].each do |offset|
149
+ idx = pos + offset
150
+ prev = @spread_fft_output[idx]
151
+ merged = prev.each_index.map { |i| [prev[i], carry[i]].max }
152
+ @spread_fft_output[idx] = merged
153
+ carry = merged
154
+ end
155
+
156
+ @spread_fft_output.append(spread)
157
+ end
158
+
159
+ def do_peak_recognition
160
+ fft_minus_46 = @fft_outputs[@fft_outputs.position - 46]
161
+ fft_minus_49 = @spread_fft_output[@spread_fft_output.position - 49]
162
+
163
+ (10...1015).each do |bin_position|
164
+ next unless fft_minus_46[bin_position] >= (1.0 / 64) &&
165
+ fft_minus_46[bin_position] >= fft_minus_49[bin_position - 1]
166
+
167
+ max_neighbor_49 = 0
168
+ ([*(-10..-4).step(3), -3, 1, *(2..8).step(3)]).each do |offset|
169
+ max_neighbor_49 = [fft_minus_49[bin_position + offset], max_neighbor_49].max
170
+ end
171
+ next unless fft_minus_46[bin_position] > max_neighbor_49
172
+
173
+ max_neighbor_other = max_neighbor_49
174
+ ([-53, -45, *(165..200).step(7), *(214..249).step(7)]).each do |offset|
175
+ idx = @spread_fft_output.position + offset
176
+ max_neighbor_other = [@spread_fft_output[idx][bin_position - 1], max_neighbor_other].max
177
+ end
178
+ next unless fft_minus_46[bin_position] > max_neighbor_other
179
+
180
+ record_peak(bin_position, fft_minus_46)
181
+ end
182
+ end
183
+
184
+ def record_peak(bin_position, fft_minus_46)
185
+ fft_number = @spread_fft_output.num_written - 46
186
+
187
+ peak_magnitude = Math.log([1.0 / 64, fft_minus_46[bin_position]].max) * 1477.3 + 6144
188
+ peak_magnitude_before = Math.log([1.0 / 64, fft_minus_46[bin_position - 1]].max) * 1477.3 + 6144
189
+ peak_magnitude_after = Math.log([1.0 / 64, fft_minus_46[bin_position + 1]].max) * 1477.3 + 6144
190
+
191
+ peak_variation_1 = peak_magnitude * 2 - peak_magnitude_before - peak_magnitude_after
192
+ return unless peak_variation_1 > 0
193
+
194
+ peak_variation_2 = (peak_magnitude_after - peak_magnitude_before) * 32 / peak_variation_1
195
+ corrected_peak_frequency_bin = bin_position * 64 + peak_variation_2
196
+
197
+ frequency_hz = corrected_peak_frequency_bin * (16_000 / 2.0 / 1024 / 64)
198
+
199
+ # NOTE: the last branch below (5500 < hz <= 5500) is unreachable by
200
+ # construction. That mirrors a quirk in the upstream Python source
201
+ # (algorithm.py) verbatim -- HZ_3500_5500 peaks are effectively never
202
+ # emitted there either. Kept as-is for behavioural parity; treat it as
203
+ # a known upstream oddity rather than a porting mistake.
204
+ band =
205
+ if frequency_hz > 250 && frequency_hz < 520
206
+ FrequencyBand::HZ_250_520
207
+ elsif frequency_hz > 520 && frequency_hz < 1450
208
+ FrequencyBand::HZ_520_1450
209
+ elsif frequency_hz > 1450 && frequency_hz < 3500
210
+ FrequencyBand::HZ_1450_3500
211
+ elsif frequency_hz > 5500 && frequency_hz <= 5500
212
+ FrequencyBand::HZ_3500_5500
213
+ end
214
+ return unless band
215
+
216
+ (@next_signature.frequency_band_to_sound_peaks[band] ||= []) << FrequencyPeak.new(
217
+ fft_number, peak_magnitude.to_i, corrected_peak_frequency_bin.to_i, 16_000
218
+ )
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ module Shazamio
7
+ # The Python project decodes arbitrary audio formats (mp3, ogg, m4a, ...)
8
+ # via `pydub`, which itself just shells out to `ffmpeg`. Ruby has no
9
+ # standard-library audio decoder either, so this does the same thing
10
+ # directly: pipe the input through `ffmpeg` and ask for raw signed 16-bit
11
+ # little-endian mono 16kHz PCM, which is exactly what the signature
12
+ # algorithm expects.
13
+ #
14
+ # Requires `ffmpeg` to be installed and on PATH (same real-world
15
+ # requirement the Python library has via pydub).
16
+ module AudioLoader
17
+ class FFmpegNotFound < StandardError; end
18
+ class DecodeError < StandardError; end
19
+
20
+ module_function
21
+
22
+ # @param source [String, Pathname, StringIO, #read] a file path, or an
23
+ # object holding raw audio bytes (e.g. a String of bytes, or anything
24
+ # responding to #read).
25
+ # @return [Array<Integer>] signed 16-bit mono samples at 16kHz
26
+ def pcm_s16le_mono_16k(source)
27
+ ensure_ffmpeg!
28
+
29
+ path, cleanup = resolve_path(source)
30
+ begin
31
+ stdout, stderr, status = Open3.capture3(
32
+ "ffmpeg", "-v", "error", "-y",
33
+ "-i", path,
34
+ "-ac", "1",
35
+ "-ar", "16000",
36
+ "-f", "s16le",
37
+ "pipe:1"
38
+ )
39
+ raise DecodeError, "ffmpeg failed: #{stderr}" unless status.success?
40
+
41
+ stdout.unpack("s<*")
42
+ ensure
43
+ cleanup&.call
44
+ end
45
+ end
46
+
47
+ def ensure_ffmpeg!
48
+ return if system("ffmpeg", "-version", out: File::NULL, err: File::NULL)
49
+
50
+ raise FFmpegNotFound, "ffmpeg was not found on PATH. Install it (e.g. `apt install ffmpeg` " \
51
+ "or `brew install ffmpeg`) to decode audio files."
52
+ end
53
+
54
+ def resolve_path(source)
55
+ case source
56
+ when String
57
+ if File.exist?(source)
58
+ [source, nil]
59
+ else
60
+ raise DecodeError,
61
+ "No such file: #{source.inspect}. `recognize`/`pcm_s16le_mono_16k` treats a String " \
62
+ "as a file path. If you meant to pass raw audio bytes instead of a path, wrap them " \
63
+ "first, e.g. StringIO.new(bytes)."
64
+ end
65
+ else
66
+ bytes = source.respond_to?(:read) ? source.read : source.to_s
67
+ tmp = Tempfile.new(["shazamio", ".audio"])
68
+ tmp.binmode
69
+ tmp.write(bytes)
70
+ tmp.close
71
+ [tmp.path, -> { tmp.unlink }]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ module Converter
5
+ module_function
6
+
7
+ # Builds the JSON payload sent to Shazam's /tag endpoint.
8
+ def data_search(timezone, uri, samplems, timestamp)
9
+ {
10
+ "timezone" => timezone,
11
+ "signature" => { "uri" => uri, "samplems" => samplems },
12
+ "timestamp" => timestamp,
13
+ "context" => {},
14
+ "geolocation" => {}
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ # Enum keys are sample rates in Hz. Used by the signature format/algorithm.
5
+ module SampleRate
6
+ NAME_BY_VALUE = {
7
+ 1 => "8000",
8
+ 2 => "11025",
9
+ 3 => "16000",
10
+ 4 => "32000",
11
+ 5 => "44100",
12
+ 6 => "48000"
13
+ }.freeze
14
+
15
+ VALUE_BY_HZ = {
16
+ 8000 => 1,
17
+ 11_025 => 2,
18
+ 16_000 => 3,
19
+ 32_000 => 4,
20
+ 44_100 => 5,
21
+ 48_000 => 6
22
+ }.freeze
23
+
24
+ def self.hz_for(value)
25
+ NAME_BY_VALUE.fetch(value).to_i
26
+ end
27
+
28
+ def self.value_for_hz(hz)
29
+ VALUE_BY_HZ.fetch(hz)
30
+ end
31
+ end
32
+
33
+ # Enum keys are frequency ranges in Hz. Used by the signature algorithm.
34
+ module FrequencyBand
35
+ HZ_0_250 = -1 # Nothing above 250 Hz is actually stored
36
+ HZ_250_520 = 0
37
+ HZ_520_1450 = 1
38
+ HZ_1450_3500 = 2
39
+ HZ_3500_5500 = 3 # Should not be used in legacy mode
40
+ end
41
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ class Error < StandardError; end
5
+
6
+ class FailedDecodeJson < Error; end
7
+ class BadMethod < Error; end
8
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ # A small, dependency-free FFT used to reimplement numpy's `np.fft.rfft`
5
+ # for the signature algorithm. Only handles power-of-two sizes, which is
6
+ # all the algorithm ever needs (a 2048-sample window).
7
+ #
8
+ # This is plain Ruby, so it is meaningfully slower than numpy/FFTW. If you
9
+ # process a lot of audio and have network access to rubygems.org, swap
10
+ # `FFT.rfft` out for a binding to FFTW (e.g. the `fftw3` gem) or Numo::NArray
11
+ # without changing any other file — `Algorithm::SignatureGenerator` only
12
+ # calls `FFT.rfft`.
13
+ module FFT
14
+ module_function
15
+
16
+ # Real FFT: takes N real samples, returns N/2+1 complex bins, matching
17
+ # numpy's np.fft.rfft for real input.
18
+ def rfft(real_samples)
19
+ n = real_samples.length
20
+ raise ArgumentError, "size must be a power of two" unless (n & (n - 1)).zero?
21
+
22
+ spectrum = fft(real_samples.map { |s| Complex(s, 0) })
23
+ spectrum[0..(n / 2)]
24
+ end
25
+
26
+ # In-place-conceptual iterative Cooley-Tukey FFT (returns a new array).
27
+ def fft(complex_samples)
28
+ n = complex_samples.length
29
+ return complex_samples.dup if n <= 1
30
+
31
+ a = bit_reverse_copy(complex_samples)
32
+
33
+ len = 2
34
+ while len <= n
35
+ ang = -2 * Math::PI / len
36
+ wlen = Complex(Math.cos(ang), Math.sin(ang))
37
+ i = 0
38
+ while i < n
39
+ w = Complex(1, 0)
40
+ (len / 2).times do |j|
41
+ u = a[i + j]
42
+ v = a[i + j + len / 2] * w
43
+ a[i + j] = u + v
44
+ a[i + j + len / 2] = u - v
45
+ w *= wlen
46
+ end
47
+ i += len
48
+ end
49
+ len <<= 1
50
+ end
51
+
52
+ a
53
+ end
54
+
55
+ def bit_reverse_copy(a)
56
+ n = a.length
57
+ bits = Math.log2(n).round
58
+ out = Array.new(n)
59
+ n.times do |i|
60
+ out[reverse_bits(i, bits)] = a[i]
61
+ end
62
+ out
63
+ end
64
+ private_class_method :bit_reverse_copy
65
+
66
+ def reverse_bits(value, bits)
67
+ result = 0
68
+ bits.times do
69
+ result = (result << 1) | (value & 1)
70
+ value >>= 1
71
+ end
72
+ result
73
+ end
74
+ private_class_method :reverse_bits
75
+ end
76
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "zlib"
7
+ require "stringio"
8
+ require_relative "exceptions"
9
+
10
+ module Shazamio
11
+ # Thin Net::HTTP wrapper with exponential-backoff retries, mirroring the
12
+ # Python client's use of aiohttp_retry.ExponentialRetry(attempts: 20,
13
+ # max_timeout: 60, statuses: {500, 502, 503, 504, 429}).
14
+ #
15
+ # Ruby has no first-class async story equivalent to asyncio/aiohttp in the
16
+ # standard library, so this client is synchronous. Wrap calls in threads or
17
+ # fibers (or use the `async`/`async-http` gems) if you need concurrency.
18
+ class HTTPClient
19
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
20
+
21
+ def initialize(attempts: 20, max_timeout: 60, open_timeout: 10, read_timeout: 30)
22
+ @attempts = attempts
23
+ @max_timeout = max_timeout
24
+ @open_timeout = open_timeout
25
+ @read_timeout = read_timeout
26
+ end
27
+
28
+ # @param method [String] "GET" or "POST"
29
+ # @param url [String]
30
+ # @param headers [Hash]
31
+ # @param params [Hash] query string params (GET only)
32
+ # @param json [Hash] JSON body (POST only)
33
+ # @param proxy [String, nil] e.g. "http://user:pass@host:port"
34
+ # @param content_type [String] expected response content type for JSON validation
35
+ def request(method, url, headers: {}, params: nil, json: nil, proxy: nil, content_type: "application/json")
36
+ uri = build_uri(url, params)
37
+
38
+ attempt = 0
39
+ backoff = 1.0
40
+
41
+ begin
42
+ attempt += 1
43
+ response = perform(method, uri, headers, json, proxy)
44
+
45
+ if RETRY_STATUSES.include?(response.code.to_i) && attempt < @attempts
46
+ sleep([backoff, @max_timeout].min)
47
+ backoff *= 2
48
+ raise Retryable
49
+ end
50
+
51
+ parse_json(response, content_type)
52
+ rescue Retryable
53
+ retry
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ class Retryable < StandardError; end
60
+
61
+ def build_uri(url, params)
62
+ uri = URI.parse(url)
63
+ if params && !params.empty?
64
+ existing = URI.decode_www_form(uri.query || "")
65
+ added = params.flat_map { |k, v| Array(v).map { |vv| [k.to_s, vv.to_s] } }
66
+ uri.query = URI.encode_www_form(existing + added)
67
+ end
68
+ uri
69
+ end
70
+
71
+ MAX_REDIRECTS = 10
72
+
73
+ def perform(method, uri, headers, json, proxy, redirects_left: MAX_REDIRECTS)
74
+ http_class = proxy_class(proxy)
75
+ http = http_class.new(uri.host, uri.port)
76
+ http.use_ssl = uri.scheme == "https"
77
+ http.open_timeout = @open_timeout
78
+ http.read_timeout = @read_timeout
79
+
80
+ request = build_request(method, uri, headers, json)
81
+
82
+ response = http.start { |conn| conn.request(request) }
83
+
84
+ # Net::HTTP does not follow redirects on its own (unlike aiohttp, which
85
+ # does by default) -- without this, a 301/302 comes back as an HTML
86
+ # redirect page instead of Shazam's JSON.
87
+ if response.is_a?(Net::HTTPRedirection) && redirects_left.positive?
88
+ location = URI.join(uri, response["location"])
89
+ return perform(method, location, headers, json, proxy, redirects_left: redirects_left - 1)
90
+ end
91
+
92
+ response
93
+ end
94
+
95
+ def proxy_class(proxy)
96
+ return Net::HTTP unless proxy
97
+
98
+ proxy_uri = URI.parse(proxy)
99
+ Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
100
+ end
101
+
102
+ def build_request(method, uri, headers, json)
103
+ request =
104
+ case method.to_s.upcase
105
+ when "GET"
106
+ Net::HTTP::Get.new(uri)
107
+ when "POST"
108
+ Net::HTTP::Post.new(uri)
109
+ else
110
+ raise BadMethod, "Accept only GET/POST"
111
+ end
112
+
113
+ headers.each { |k, v| request[k] = v }
114
+
115
+ if json
116
+ request["Content-Type"] = "application/json"
117
+ request.body = JSON.generate(json)
118
+ end
119
+
120
+ request
121
+ end
122
+
123
+ def parse_json(response, content_type)
124
+ body = decode_body(response)
125
+
126
+ actual_type = response["Content-Type"].to_s.split(";").first
127
+ if content_type && actual_type && !actual_type.empty? && actual_type != content_type
128
+ raise FailedDecodeJson,
129
+ "Failed to decode json (status #{response.code}, content-type was #{actual_type}). " \
130
+ "Body started with: #{body.to_s[0, 200].inspect}"
131
+ end
132
+
133
+ JSON.parse(body)
134
+ rescue JSON::ParserError => e
135
+ raise FailedDecodeJson, "Failed to decode json: #{e.message}. Body started with: #{body.to_s[0, 200].inspect}"
136
+ end
137
+
138
+ def decode_body(response)
139
+ case response["Content-Encoding"]
140
+ when "gzip"
141
+ Zlib::GzipReader.new(StringIO.new(response.body)).read
142
+ when "deflate"
143
+ Zlib::Inflate.inflate(response.body)
144
+ else
145
+ response.body
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "user_agent"
4
+
5
+ module Shazamio
6
+ # Mirrors the Python `Device` enum: a client platform Shazam is spoofed as.
7
+ module Device
8
+ IPHONE = "iphone"
9
+ ANDROID = "android"
10
+ WEB = "web"
11
+ ALL = [IPHONE, ANDROID, WEB].freeze
12
+
13
+ def self.random
14
+ ALL.sample
15
+ end
16
+ end
17
+
18
+ # Shared request concerns: timezone + Shazam-flavoured headers.
19
+ module Request
20
+ TIME_ZONE = "Europe/Moscow"
21
+
22
+ def headers
23
+ {
24
+ "X-Shazam-Platform" => "IPHONE",
25
+ "X-Shazam-AppVersion" => "14.1.0",
26
+ "Accept" => "*/*",
27
+ "Accept-Language" => language,
28
+ "Accept-Encoding" => "gzip, deflate",
29
+ "User-Agent" => USER_AGENTS.sample
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ # The Python project uses Pydantic + dataclass-factory to map Shazam's raw
5
+ # JSON onto a large tree of typed models (see shazamio/schemas/**). Porting
6
+ # that whole schema tree 1:1 would be a lot of low-value boilerplate, so
7
+ # this Ruby port instead exposes the handful of fields people actually use
8
+ # (mirroring factory.py's `name_mapping`s) as plain structs, while always
9
+ # keeping the full raw Hash available too via `.raw`.
10
+ module Serialize
11
+ Track = Struct.new(
12
+ :title, :subtitle, :artist_id, :photo_url, :ringtone,
13
+ :apple_music_url, :spotify_url, :spotify_uri, :shazam_url, :raw,
14
+ keyword_init: true
15
+ )
16
+
17
+ Artist = Struct.new(
18
+ :adam_id, :name, :avatar, :url, :genres_primary, :genres, :raw,
19
+ keyword_init: true
20
+ )
21
+
22
+ module_function
23
+
24
+ # @param data [Hash] one "track" object as returned by e.g. #track_about,
25
+ # #search_track, #top_world_tracks, etc.
26
+ def track(data)
27
+ hub = data["hub"] || {}
28
+ options = (hub["options"] || [])[0] || {}
29
+ providers = (hub["providers"] || [])[0] || {}
30
+ actions = hub["actions"] || []
31
+
32
+ Track.new(
33
+ title: data["title"],
34
+ subtitle: data["subtitle"],
35
+ artist_id: data.dig("artists", 0, "id"),
36
+ photo_url: data.dig("images", "coverarthq"),
37
+ ringtone: actions.dig(1, "uri"),
38
+ apple_music_url: options.dig("actions", 0, "uri"),
39
+ spotify_url: providers.dig("actions", 0, "uri"),
40
+ spotify_uri: providers.dig("actions", 1, "uri"),
41
+ shazam_url: data["url"],
42
+ raw: data
43
+ )
44
+ end
45
+
46
+ # @param data [Hash] one "artist" object, e.g. from #search_artist hits,
47
+ # or the top-level result of #artist_about (legacy v1 shape).
48
+ def artist(data)
49
+ attributes = data["attributes"] || data
50
+ genres = data["genres"] || {}
51
+
52
+ Artist.new(
53
+ adam_id: data["adamid"] || data["id"],
54
+ name: attributes["name"] || data["name"],
55
+ avatar: data["avatar"],
56
+ url: attributes["url"] || data["weburl"],
57
+ genres_primary: genres["primary"],
58
+ genres: genres["secondaries"],
59
+ raw: data
60
+ )
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "request"
6
+ require_relative "urls"
7
+ require_relative "http_client"
8
+ require_relative "converter"
9
+ require_relative "algorithm"
10
+ require_relative "audio_loader"
11
+ require_relative "enums"
12
+
13
+ module Shazamio
14
+ # Ruby port of shazamio.api.Shazam. Synchronous (see HTTPClient for why),
15
+ # so no asyncio/aiohttp equivalent is needed -- just call methods directly.
16
+ #
17
+ # shazam = Shazamio::Shazam.new
18
+ # result = shazam.recognize("song.mp3")
19
+ # puts result.dig("track", "title")
20
+ #
21
+ # NOTE: this only exposes the endpoints that are currently reachable. A
22
+ # separate group of endpoints (artist info, artist albums, album info, and
23
+ # every "top tracks" chart) is broken on Shazam's side right now -- their
24
+ # own web app hits the same error -- and was removed from here rather than
25
+ # shipped broken. See the "Not included" section of README.md for details,
26
+ # including how to bring them back if/when Apple fixes it upstream.
27
+ class Shazam
28
+ include Request
29
+
30
+ attr_reader :language, :endpoint_country, :http_client
31
+
32
+ def initialize(language: "en-US", endpoint_country: "GB", http_client: nil)
33
+ @language = language
34
+ @endpoint_country = endpoint_country
35
+ @http_client = http_client || HTTPClient.new
36
+ end
37
+
38
+ # Recognize a track from an audio file path, raw audio bytes (wrapped in
39
+ # e.g. StringIO), or an Array of signed 16-bit PCM samples already at
40
+ # 16kHz mono.
41
+ #
42
+ # There is no Ruby equivalent of the Python project's compiled Rust
43
+ # recognizer (`shazamio_core`); this always uses the pure-Ruby signature
44
+ # algorithm (see Algorithm::SignatureGenerator), which is Python's
45
+ # "legacy" path (`recognize_song`). It works the same way, just slower.
46
+ #
47
+ # @param data [String, Array<Integer>, #read] file path, audio bytes (via
48
+ # an IO-like object), or PCM samples
49
+ # @param proxy [String, nil]
50
+ # @return [Hash] Shazam's raw JSON response
51
+ def recognize(data, proxy: nil)
52
+ samples =
53
+ if data.is_a?(Array)
54
+ data
55
+ else
56
+ AudioLoader.pcm_s16le_mono_16k(data)
57
+ end
58
+
59
+ generator = SignatureGenerator.new
60
+ generator.feed_input(samples)
61
+ generator.max_time_seconds = 12
62
+
63
+ duration_seconds = samples.length / 16_000.0
64
+ if duration_seconds > 12 * 3
65
+ generator.samples_processed += 16_000 * ((duration_seconds / 2).to_i - 6)
66
+ end
67
+
68
+ signature = generator.get_next_signature
69
+ return { "matches" => [] } if signature.nil?
70
+
71
+ send_recognize_request(signature, proxy: proxy)
72
+ end
73
+
74
+ def send_recognize_request(signature, proxy: nil)
75
+ body = Converter.data_search(
76
+ Request::TIME_ZONE,
77
+ signature.encode_to_uri,
78
+ (signature.number_samples / signature.sample_rate_hz.to_f * 1000).to_i,
79
+ (Time.now.to_f * 1000).to_i
80
+ )
81
+
82
+ url = format(
83
+ ShazamUrl::SEARCH_FROM_FILE,
84
+ language: language,
85
+ device: Device.random,
86
+ endpoint_country: endpoint_country,
87
+ uuid_1: SecureRandom.uuid.upcase,
88
+ uuid_2: SecureRandom.uuid.upcase
89
+ )
90
+
91
+ http_client.request("POST", url, headers: headers, json: body, proxy: proxy)
92
+ end
93
+
94
+ def track_about(track_id, proxy: nil)
95
+ url = format(ShazamUrl::ABOUT_TRACK, language: language, endpoint_country: endpoint_country, track_id: track_id)
96
+ http_client.request("GET", url, headers: headers, proxy: proxy)
97
+ end
98
+
99
+ def search_artist(query, limit: 10, offset: 0, proxy: nil)
100
+ url = format(
101
+ ShazamUrl::SEARCH_ARTIST, language: language, endpoint_country: endpoint_country,
102
+ limit: limit, offset: offset, query: cgi_escape(query)
103
+ )
104
+ http_client.request("GET", url, headers: headers, proxy: proxy)
105
+ end
106
+
107
+ def search_track(query, limit: 10, offset: 0, proxy: nil)
108
+ url = format(
109
+ ShazamUrl::SEARCH_MUSIC, language: language, endpoint_country: endpoint_country,
110
+ limit: limit, offset: offset, query: cgi_escape(query)
111
+ )
112
+ http_client.request("GET", url, headers: headers, proxy: proxy)
113
+ end
114
+
115
+ def related_tracks(track_id, limit: 20, offset: 0, proxy: nil)
116
+ url = format(
117
+ ShazamUrl::RELATED_SONGS, language: language, endpoint_country: endpoint_country,
118
+ limit: limit, offset: offset, track_id: track_id
119
+ )
120
+ http_client.request("GET", url, headers: headers, proxy: proxy)
121
+ end
122
+
123
+ def listening_counter(track_id, proxy: nil)
124
+ url = format(ShazamUrl::LISTENING_COUNTER, track_id: track_id)
125
+ http_client.request("GET", url, headers: headers, proxy: proxy)
126
+ end
127
+
128
+ def listening_counter_many(track_ids, proxy: nil)
129
+ http_client.request(
130
+ "GET", ShazamUrl::LISTENING_COUNTER_MANY,
131
+ headers: headers, params: { "id" => track_ids }, proxy: proxy
132
+ )
133
+ end
134
+
135
+ def get_youtube_data(link, proxy: nil)
136
+ http_client.request("GET", link, headers: headers, proxy: proxy)
137
+ end
138
+
139
+ private
140
+
141
+ def cgi_escape(str)
142
+ require "cgi"
143
+ CGI.escape(str.to_s)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "zlib"
5
+ require "stringio"
6
+ require_relative "enums"
7
+
8
+ module Shazamio
9
+ DATA_URI_PREFIX = "data:audio/vnd.shazam.sig;base64,"
10
+
11
+ # A single detected FFT peak: which frequency band, at what time offset,
12
+ # magnitude and (corrected) frequency bin. Ported from signature.py.
13
+ class FrequencyPeak
14
+ attr_accessor :fft_pass_number, :peak_magnitude, :corrected_peak_frequency_bin, :sample_rate_hz
15
+
16
+ def initialize(fft_pass_number, peak_magnitude, corrected_peak_frequency_bin, sample_rate_hz)
17
+ @fft_pass_number = fft_pass_number
18
+ @peak_magnitude = peak_magnitude
19
+ @corrected_peak_frequency_bin = corrected_peak_frequency_bin
20
+ @sample_rate_hz = sample_rate_hz
21
+ end
22
+
23
+ def frequency_hz
24
+ corrected_peak_frequency_bin * (sample_rate_hz / 2.0 / 1024 / 64)
25
+ end
26
+
27
+ def amplitude_pcm
28
+ Math.sqrt(Math.exp((peak_magnitude - 6144) / 1477.3) * (1 << 17) / 2) / 1024
29
+ end
30
+
31
+ def seconds
32
+ (fft_pass_number * 128).to_f / sample_rate_hz
33
+ end
34
+ end
35
+
36
+ # Shazam's raw binary "signature" format: a 48-byte header (magic numbers,
37
+ # CRC32, sample count) followed by a TLV list of frequency-band peaks.
38
+ # Ported field-for-field from signature.py's RawSignatureHeader /
39
+ # DecodedMessage, replacing ctypes.Structure with Array#pack/#unpack.
40
+ class DecodedMessage
41
+ HEADER_FORMAT = "V12" # 12 x uint32 little-endian = 48 bytes
42
+ MAGIC1 = 0xCAFE2580
43
+ MAGIC2 = 0x94119C00
44
+
45
+ attr_accessor :sample_rate_hz, :number_samples, :frequency_band_to_sound_peaks
46
+
47
+ def initialize
48
+ @frequency_band_to_sound_peaks = {}
49
+ end
50
+
51
+ def self.decode_from_binary(data)
52
+ msg = new
53
+ buf = StringIO.new(data)
54
+ buf.binmode
55
+
56
+ buf.seek(8)
57
+ check_summable_data = buf.read
58
+ buf.seek(0)
59
+
60
+ header_bytes = buf.read(48)
61
+ fields = header_bytes.unpack(HEADER_FORMAT)
62
+ magic1, crc32, size_minus_header, magic2, _v1a, _v1b, _v1c,
63
+ shifted_sample_rate_id, _v2a, _v2b, number_samples_plus_divided_sample_rate, _fixed_value = fields
64
+
65
+ raise "bad magic1" unless magic1 == MAGIC1
66
+ raise "bad size" unless size_minus_header == data.bytesize - 48
67
+ raise "bad crc32" unless (Zlib.crc32(check_summable_data) & 0xFFFFFFFF) == crc32
68
+ raise "bad magic2" unless magic2 == MAGIC2
69
+
70
+ msg.sample_rate_hz = SampleRate.hz_for(shifted_sample_rate_id >> 27)
71
+ msg.number_samples = (number_samples_plus_divided_sample_rate - msg.sample_rate_hz * 0.24).to_i
72
+
73
+ raise "bad tlv preamble" unless buf.read(4).unpack1("V") == 0x40000000
74
+ raise "bad tlv size" unless buf.read(4).unpack1("V") == data.bytesize - 48
75
+
76
+ msg.frequency_band_to_sound_peaks = {}
77
+
78
+ until buf.eof?
79
+ tlv_header = buf.read(8)
80
+ break if tlv_header.nil? || tlv_header.empty?
81
+
82
+ frequency_band_id, frequency_peaks_size = tlv_header.unpack("VV")
83
+ padding = (-frequency_peaks_size) % 4
84
+
85
+ peaks_buf = StringIO.new(buf.read(frequency_peaks_size))
86
+ buf.read(padding)
87
+
88
+ band = frequency_band_id - 0x60030040
89
+ fft_pass_number = 0
90
+ msg.frequency_band_to_sound_peaks[band] = []
91
+
92
+ until peaks_buf.eof?
93
+ raw_offset = peaks_buf.read(1)
94
+ break if raw_offset.nil? || raw_offset.empty?
95
+
96
+ offset = raw_offset.unpack1("C")
97
+ if offset == 0xFF
98
+ fft_pass_number = peaks_buf.read(4).unpack1("V")
99
+ next
100
+ else
101
+ fft_pass_number += offset
102
+ end
103
+
104
+ peak_magnitude = peaks_buf.read(2).unpack1("v")
105
+ corrected_peak_frequency_bin = peaks_buf.read(2).unpack1("v")
106
+
107
+ msg.frequency_band_to_sound_peaks[band] << FrequencyPeak.new(
108
+ fft_pass_number, peak_magnitude, corrected_peak_frequency_bin, msg.sample_rate_hz
109
+ )
110
+ end
111
+ end
112
+
113
+ msg
114
+ end
115
+
116
+ def encode_to_binary
117
+ contents_buf = StringIO.new
118
+ contents_buf.binmode
119
+
120
+ frequency_band_to_sound_peaks.sort.each do |frequency_band, frequency_peaks|
121
+ peaks_buf = StringIO.new
122
+ peaks_buf.binmode
123
+ fft_pass_number = 0
124
+
125
+ frequency_peaks.each do |peak|
126
+ raise "peaks must be sorted by fft_pass_number" if peak.fft_pass_number < fft_pass_number
127
+
128
+ if peak.fft_pass_number - fft_pass_number >= 255
129
+ peaks_buf.write([0xFF].pack("C"))
130
+ peaks_buf.write([peak.fft_pass_number].pack("V"))
131
+ fft_pass_number = peak.fft_pass_number
132
+ end
133
+
134
+ peaks_buf.write([peak.fft_pass_number - fft_pass_number].pack("C"))
135
+ peaks_buf.write([peak.peak_magnitude].pack("v"))
136
+ peaks_buf.write([peak.corrected_peak_frequency_bin].pack("v"))
137
+
138
+ fft_pass_number = peak.fft_pass_number
139
+ end
140
+
141
+ peaks_bytes = peaks_buf.string
142
+ contents_buf.write([0x60030040 + frequency_band].pack("V"))
143
+ contents_buf.write([peaks_bytes.bytesize].pack("V"))
144
+ contents_buf.write(peaks_bytes)
145
+ contents_buf.write("\x00" * ((-peaks_bytes.bytesize) % 4))
146
+ end
147
+
148
+ contents = contents_buf.string
149
+ size_minus_header = contents.bytesize + 8
150
+ shifted_sample_rate_id = SampleRate.value_for_hz(sample_rate_hz) << 27
151
+ fixed_value = (15 << 19) + 0x40000
152
+ number_samples_plus_divided_sample_rate = (number_samples + sample_rate_hz * 0.24).to_i
153
+
154
+ # First pass: header with crc32 = 0, to compute the real crc32 over it.
155
+ header_fields = [
156
+ MAGIC1, 0, size_minus_header, MAGIC2,
157
+ 0, 0, 0,
158
+ shifted_sample_rate_id,
159
+ 0, 0,
160
+ number_samples_plus_divided_sample_rate,
161
+ fixed_value
162
+ ]
163
+
164
+ body = StringIO.new
165
+ body.binmode
166
+ body.write([0x40000000].pack("V"))
167
+ body.write([contents.bytesize + 8].pack("V"))
168
+ body.write(contents)
169
+
170
+ crc = Zlib.crc32(header_fields.pack(HEADER_FORMAT)[8..] + body.string) & 0xFFFFFFFF
171
+ header_fields[1] = crc
172
+
173
+ header_fields.pack(HEADER_FORMAT) + body.string
174
+ end
175
+
176
+ def encode_to_uri
177
+ DATA_URI_PREFIX + Base64.strict_encode64(encode_to_binary)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ # Endpoint URL templates, ported from the Python client's ShazamUrl.
5
+ # `%{...}` placeholders are filled in with String#% at call sites.
6
+ #
7
+ # NOTE: the Python source also defines TOP_TRACKS_PLAYLIST, LOCATIONS,
8
+ # SEARCH_ARTIST_V2 (artist info), ARTIST_ALBUMS, and ARTIST_ALBUM_INFO --
9
+ # all under https://www.shazam.com/services/amapi/v1/catalog/... . That
10
+ # whole `amapi` catalog path is currently rejecting GET requests with a
11
+ # 405 on Shazam's own servers (confirmed: shazam.com's own web app hits
12
+ # the same 405 on this path). Since there's nothing a client can do about
13
+ # a server-side 405, those routes were left out here rather than shipped
14
+ # broken. See README.md ("Not included") for the exact URLs, in case
15
+ # Apple fixes this and someone wants to add them back.
16
+ module ShazamUrl
17
+ SEARCH_FROM_FILE =
18
+ "https://amp.shazam.com/discovery/v5/%<language>s/%<endpoint_country>s/%<device>s/-/tag" \
19
+ "/%<uuid_1>s/%<uuid_2>s?sync=true&webv3=true&sampling=true" \
20
+ "&connected=&shazamapiversion=v3&sharehub=true&hubv5minorversion=v5.1&hidelb=true&video=v3"
21
+
22
+ ABOUT_TRACK =
23
+ "https://www.shazam.com/discovery/v5/%<language>s/%<endpoint_country>s/web/-/track" \
24
+ "/%<track_id>s?shazamapiversion=v3&video=v3"
25
+
26
+ RELATED_SONGS =
27
+ "https://cdn.shazam.com/shazam/v3/%<language>s/%<endpoint_country>s/web/-/tracks" \
28
+ "/track-similarities-id-%<track_id>s?startFrom=%<offset>s&pageSize=%<limit>s&connected=&channel="
29
+
30
+ SEARCH_ARTIST =
31
+ "https://www.shazam.com/services/search/v4/%<language>s/%<endpoint_country>s/web" \
32
+ "/search?term=%<query>s&limit=%<limit>s&offset=%<offset>s&types=artists"
33
+
34
+ SEARCH_MUSIC =
35
+ "https://www.shazam.com/services/search/v3/%<language>s/%<endpoint_country>s/web" \
36
+ "/search?query=%<query>s&numResults=%<limit>s&offset=%<offset>s&types=songs"
37
+
38
+ LISTENING_COUNTER = "https://www.shazam.com/services/count/v2/web/track/%<track_id>s"
39
+ LISTENING_COUNTER_MANY = "https://www.shazam.com/services/count/v2/web/track"
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ # A pool of realistic desktop/mobile User-Agent strings. A random one is
5
+ # attached to every outgoing request, mirroring the Python client's
6
+ # behaviour of rotating user agents.
7
+ USER_AGENTS = [
8
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
9
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
10
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
11
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
13
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
14
+ "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
15
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
16
+ "Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36",
17
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/124.0.0.0 Safari/537.36",
18
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
19
+ "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
20
+ "Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
21
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
22
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15",
23
+ "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
24
+ "Mozilla/5.0 (Linux; Android 14; SM-A536E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
25
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 OPR/108.0.0.0",
26
+ "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko",
27
+ "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)"
28
+ ].freeze
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shazamio
4
+ VERSION = "0.1.0"
5
+ end
data/lib/shazamio.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shazamio/version"
4
+ require_relative "shazamio/exceptions"
5
+ require_relative "shazamio/enums"
6
+ require_relative "shazamio/user_agent"
7
+ require_relative "shazamio/urls"
8
+ require_relative "shazamio/request"
9
+ require_relative "shazamio/http_client"
10
+ require_relative "shazamio/converter"
11
+ require_relative "shazamio/fft"
12
+ require_relative "shazamio/signature"
13
+ require_relative "shazamio/algorithm"
14
+ require_relative "shazamio/audio_loader"
15
+ require_relative "shazamio/serialize"
16
+ require_relative "shazamio/shazam"
17
+
18
+ module Shazamio
19
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shazamio-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ShazamIO Ruby port contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.13'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: webmock
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.23'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.23'
40
+ description: |
41
+ A free Ruby library for the reverse engineered Shazam API. Search tracks and
42
+ artists, fetch charts, and recognize songs from an audio file using a pure
43
+ Ruby port of Shazam's audio fingerprinting algorithm.
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - lib/shazamio.rb
50
+ - lib/shazamio/algorithm.rb
51
+ - lib/shazamio/audio_loader.rb
52
+ - lib/shazamio/converter.rb
53
+ - lib/shazamio/enums.rb
54
+ - lib/shazamio/exceptions.rb
55
+ - lib/shazamio/fft.rb
56
+ - lib/shazamio/http_client.rb
57
+ - lib/shazamio/request.rb
58
+ - lib/shazamio/serialize.rb
59
+ - lib/shazamio/shazam.rb
60
+ - lib/shazamio/signature.rb
61
+ - lib/shazamio/urls.rb
62
+ - lib/shazamio/user_agent.rb
63
+ - lib/shazamio/version.rb
64
+ homepage: https://github.com/shazamio/ShazamIO
65
+ licenses:
66
+ - MIT
67
+ metadata: {}
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.9
83
+ specification_version: 4
84
+ summary: Ruby client for the reverse engineered Shazam API (port of Python's shazamio)
85
+ test_files: []