rocksky 0.10.0 → 0.11.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 506387a27a8e92d1310a65f855110efff853b6079d9dbb65d6a3e1e56932cc18
4
- data.tar.gz: 9d190b167472ba41881d547b527e501e446a75d844cefd4ff5b3200cd461cc94
3
+ metadata.gz: 6285f01e3baa73b2791e6b64a2d4d331aa750abe5f50d1e4cafe6263073bf9ad
4
+ data.tar.gz: 1668269cb59db82bfaf6455e66e493f7dfd9a1c97c5037d9b3eac959a60dd3cf
5
5
  SHA512:
6
- metadata.gz: 1a82ef8bb90f40689b29067ba2debfe55eb8e099ed0897c3a69b795a3f03e76c49b10ba42d2035377027ea6701b53ac7be2195b2aead08f0a609ebd4e3058e9c
7
- data.tar.gz: 78ca04c14127de3ae2b6993a942129ad1d9118679f1c8458807d315744e703932ae3c861fb32349fb615dff4d4086207bc38e19a2361d27a8fa047291576addf
6
+ metadata.gz: 50c4b9e1f82ce55ea8b9370946461dc8fd9bc6c87ce108ac304cf2835a66b0e6474c7cd07cd1c5a77c6794800cc7082a63abb1e45cc8395dfb8b7a07e82d3aed
7
+ data.tar.gz: 57b4fd741109bd4cc218d68520b22fef8f5dc5bfa2f1494ac7439722e892d5e60112eb026bb9cc65f8aaa784a537bbacf9078c939b27b859d646b854b85d1993
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ - Added `Rocksky::Filter`, a fluent RSQL builder (`eq`, `ne`, `gt`, `ge`,
6
+ `lt`, `le`, `is_in`, `is_out`, `is_null`, `is_not_null`, combined with
7
+ `#and` / `#or`). Fields are Symbols (`:artist`, `:"track.artist"`); values
8
+ are quoted/escaped automatically, `*` wildcards stay bare.
9
+ - Added filterable reads: `Rocksky.catalog_songs`, `catalog_artists`,
10
+ `catalog_albums` (`limit:`, `offset:`, `genre:`, `filter:`) and
11
+ `Rocksky.scrobble_feed` (`did:`, `following:`, `limit:`, `offset:`,
12
+ `filter:`). `filter:` takes a `Rocksky::Filter` or a raw RSQL String.
13
+
3
14
  ## 0.10.0
4
15
 
5
16
  - Remote-player protocol: now-playing records accept optional audio info —
data/README.md CHANGED
@@ -14,7 +14,7 @@ gem install rocksky
14
14
  Or in a Gemfile:
15
15
 
16
16
  ```ruby
17
- gem "rocksky", "~> 0.7"
17
+ gem "rocksky", "~> 0.11"
18
18
  ```
19
19
 
20
20
  The gem is pure-Ruby; the native library is fetched from the GitHub release on
@@ -83,6 +83,31 @@ Rocksky.top_artists_interval(limit: 5, interval: :all)
83
83
  **Match** — `Rocksky.match_song(title, artist, mb_id: nil, isrc: nil)` resolves a
84
84
  bare title + artist into full canonical metadata.
85
85
 
86
+ ### Filtering
87
+
88
+ `Rocksky::Filter` builds RSQL expressions for the `filter:` kwarg of
89
+ `catalog_songs`, `catalog_artists`, `catalog_albums` and `scrobble_feed`
90
+ (each also takes `limit:`, `offset:` and — catalogs only — `genre:`). Fields
91
+ are Symbols; dotted selectors on the scrobble feed reach the joined
92
+ track/user/artist (`:"track.artist"`, `:"user.handle"`, …).
93
+
94
+ ```ruby
95
+ filter = Rocksky::Filter.eq(:artist, "Daft Punk")
96
+ .and(Rocksky::Filter.gt(:duration, 200_000))
97
+ .or(Rocksky::Filter.is_in(:genre, %w[house electro]))
98
+ filter.to_s # => artist=="Daft Punk";duration=gt=200000,genre=in=(house,electro)
99
+
100
+ Rocksky.catalog_songs(limit: 20, filter: filter)
101
+ Rocksky.scrobble_feed(filter: Rocksky::Filter.eq(:"track.artist", "Daft Punk"))
102
+ ```
103
+
104
+ Constructors: `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `is_in`, `is_out` (aliases
105
+ `in`/`out`), `is_null`, `is_not_null`. Combine with `#and` (`;`) and `#or`
106
+ (`,`); an OR operand inside an AND is parenthesized automatically. String
107
+ values are quoted/escaped when they contain reserved characters, and `*`
108
+ wildcards pass through unquoted (`Filter.eq(:artist, "Daft*")`). A raw RSQL
109
+ String is accepted anywhere a `Filter` is.
110
+
86
111
  ### Writes — `Rocksky::Agent`
87
112
 
88
113
  `Agent.login(session_path, identifier, password, appview:, dedup_path:)` → an
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Standalone — no native core involved. Loads on its own via
4
+ # `require "rocksky/filter"`.
5
+ module Rocksky
6
+ # Fluent builder for RSQL filter expressions, accepted by the +filter:+
7
+ # parameter of the catalog and scrobble-feed queries
8
+ # (app.rocksky.song.getSongs, app.rocksky.artist.getArtists,
9
+ # app.rocksky.album.getAlbums, app.rocksky.scrobble.getScrobbles).
10
+ #
11
+ # require "rocksky"
12
+ #
13
+ # filter = Rocksky::Filter.eq(:artist, "Daft Punk")
14
+ # .and(Rocksky::Filter.gt(:duration, 200_000))
15
+ # .or(Rocksky::Filter.is_in(:genre, %w[house electro]))
16
+ #
17
+ # Rocksky.catalog_songs(filter: filter)
18
+ # # artist=="Daft Punk";duration=gt=200000,genre=in=(house,electro)
19
+ #
20
+ # Fields are Symbols (Strings work too); dotted selectors on the scrobble
21
+ # feed are written as +:"track.artist"+. String values are quoted and
22
+ # escaped automatically when they contain characters RSQL reserves; +*+
23
+ # wildcards pass through unquoted so <tt>Filter.eq(:artist, "Daft*")</tt>
24
+ # performs a case-insensitive match.
25
+ class Filter
26
+ # Characters that never need quoting in an RSQL value (+*+ kept bare so
27
+ # wildcards work).
28
+ SAFE_VALUE = /\A[A-Za-z0-9_.:@*+-]+\z/
29
+ private_constant :SAFE_VALUE
30
+
31
+ class << self
32
+ # +field==value+ — equals; +*+ in string values is a wildcard.
33
+ def eq(field, value)
34
+ comparison(field, "==", value)
35
+ end
36
+
37
+ # +field!=value+ — not equals.
38
+ def ne(field, value)
39
+ comparison(field, "!=", value)
40
+ end
41
+
42
+ # +field=gt=value+ — greater than.
43
+ def gt(field, value)
44
+ comparison(field, "=gt=", value)
45
+ end
46
+
47
+ # +field=ge=value+ — greater than or equal.
48
+ def ge(field, value)
49
+ comparison(field, "=ge=", value)
50
+ end
51
+
52
+ # +field=lt=value+ — less than.
53
+ def lt(field, value)
54
+ comparison(field, "=lt=", value)
55
+ end
56
+
57
+ # +field=le=value+ — less than or equal.
58
+ def le(field, value)
59
+ comparison(field, "=le=", value)
60
+ end
61
+
62
+ # +field=in=(a,b)+ — matches any of the values.
63
+ def is_in(field, values)
64
+ list(field, "=in=", values)
65
+ end
66
+
67
+ # +field=out=(a,b)+ — matches none of the values.
68
+ def is_out(field, values)
69
+ list(field, "=out=", values)
70
+ end
71
+
72
+ alias in is_in
73
+ alias out is_out
74
+
75
+ # +field==null+ — the field is NULL.
76
+ def is_null(field)
77
+ new("#{field}==null", :comparison)
78
+ end
79
+
80
+ # +field!=null+ — the field is not NULL.
81
+ def is_not_null(field)
82
+ new("#{field}!=null", :comparison)
83
+ end
84
+
85
+ private
86
+
87
+ def comparison(field, op, value)
88
+ new("#{field}#{op}#{render_value(value)}", :comparison)
89
+ end
90
+
91
+ def list(field, op, values)
92
+ name = op == "=in=" ? "is_in" : "is_out"
93
+ raise ArgumentError, "Filter.#{name}(#{field.inspect}, ...) needs at least one value" if values.empty?
94
+
95
+ new("#{field}#{op}(#{values.map { |v| render_value(v) }.join(",")})", :comparison)
96
+ end
97
+
98
+ def render_value(value)
99
+ return value.to_s if value.is_a?(Integer) || value.is_a?(Float) ||
100
+ value == true || value == false
101
+
102
+ str = value.to_s
103
+ return str if !str.empty? && SAFE_VALUE.match?(str)
104
+
105
+ %("#{str.gsub(/(["\\])/) { "\\#{Regexp.last_match(1)}" }}")
106
+ end
107
+ end
108
+
109
+ def initialize(expr, kind)
110
+ @expr = expr
111
+ @kind = kind
112
+ end
113
+
114
+ # Both sides must match (+;+). An +or+ operand is parenthesized to keep
115
+ # RSQL precedence.
116
+ def and(other)
117
+ Filter.new("#{operand_in_and};#{other.operand_in_and}", :and)
118
+ end
119
+
120
+ # Either side may match (+,+). Never parenthesizes.
121
+ def or(other)
122
+ Filter.new("#{@expr},#{other.build}", :or)
123
+ end
124
+
125
+ # The RSQL expression string to send as the +filter+ query param.
126
+ def build
127
+ @expr
128
+ end
129
+
130
+ alias to_s build
131
+
132
+ protected
133
+
134
+ def operand_in_and
135
+ @kind == :or ? "(#{@expr})" : @expr
136
+ end
137
+ end
138
+ end
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "repo": "tsirysndr/rocksky",
3
- "tag": "bindings-v0.7.0",
3
+ "tag": "bindings-v0.10.0",
4
4
  "checksums": {
5
- "aarch64-apple-darwin": "e9a772586564269dccf8448d46162b85fe5290307b818b3a4fc26ff634c2ab57",
6
- "aarch64-linux-gnu": "fbfc5d12a8c532f437577f1c2e36c794760e78bb66749af33eef17d14a60cafc",
7
- "x86_64-apple-darwin": "81e5628ea2f269ac8efefce85fa5eae093b1d49848aba580da9f267a36f85115",
8
- "x86_64-linux-gnu": "3d3de1e3b691416676dd1c09879b14944a5c85d1d8911650113e6ee7783326e2",
9
- "x86_64-unknown-freebsd": "b0ad8071e2a6baf1096d4f82221c440e56bf9426e435244ade8687f08edabf69",
10
- "x86_64-unknown-netbsd": "08e9ff48d6273e63d6ebdc5eb3882777bc9bd227bc9126478a98c5041e2f1f05"
5
+ "aarch64-apple-darwin": "36607cf75cc05b79f93570efae0047dfe58f4beff38033f59e9c8c9e03c0e62e",
6
+ "aarch64-linux-gnu": "d5fcf8a3ca7acba5eaf45db0478360ade6ebfcea7f1dab582ff10696ec77c24e",
7
+ "x86_64-apple-darwin": "a19daa282e01fa68cb279da28bba52198c928c7771770d4e0a5e10e922a51aa9",
8
+ "x86_64-linux-gnu": "a6751be73f81d32639b85f3a1f2c6420b7a2346acbc59a5fd534156915d2f918",
9
+ "x86_64-unknown-freebsd": "e811d322e14ce51498928616c02f0c6228ea765a7f67efef00963a1bbe0c172e",
10
+ "x86_64-unknown-netbsd": "ce8e555d3ef6caee7dd7c7302feb14b73268ae46dcc9c517d8370c57c93624c6"
11
11
  }
12
12
  }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rocksky
4
+ # app.rocksky.playlist.* — the global, AT-Proto-backed playlists.
5
+ #
6
+ # Distinct from Rocksky::Library's playlist methods, which drive the
7
+ # Subsonic/Navidrome library (app.rocksky.library.*).
8
+ #
9
+ # Writes publish records to the caller's repo, so they need a +token+ and only
10
+ # show up in reads once the AppView has ingested the commit.
11
+ module Playlist
12
+ module_function
13
+
14
+ # The playlist catalog.
15
+ def list(limit: 50, offset: 0, filter: nil, base: nil)
16
+ params = { limit: limit, offset: offset }
17
+ params[:filter] = filter if filter
18
+ Rocksky.get("app.rocksky.playlist.getPlaylists", params, base: base)
19
+ end
20
+
21
+ # A single playlist with its tracks. +filter+ is an RSQL expression over the
22
+ # tracks (e.g. 'artist=="Daft Punk"').
23
+ def get(uri, filter: nil, base: nil)
24
+ params = { uri: uri }
25
+ params[:filter] = filter if filter
26
+ Rocksky.get("app.rocksky.playlist.getPlaylist", params, base: base)
27
+ end
28
+
29
+ # Create a playlist. Returns { "uri" => ..., "cid" => ... }.
30
+ def create(name, token:, description: nil, picture_url: nil, base: nil)
31
+ params = { name: name }
32
+ params[:description] = description if description
33
+ params[:pictureUrl] = picture_url if picture_url
34
+ Rocksky.post("app.rocksky.playlist.createPlaylist", params, base: base, token: token)
35
+ end
36
+
37
+ # Rename or re-describe a playlist. Owner only.
38
+ def update(uri, token:, name: nil, description: nil, picture_url: nil, base: nil)
39
+ params = { uri: uri }
40
+ params[:name] = name if name
41
+ params[:description] = description if description
42
+ params[:pictureUrl] = picture_url if picture_url
43
+ Rocksky.post("app.rocksky.playlist.updatePlaylist", params, base: base, token: token)
44
+ end
45
+
46
+ # Add songs by their app.rocksky.song AT-URIs. Owner only. Returns
47
+ # { "uris" => [...] } — the created entry records.
48
+ def add_songs(uri, songs, token:, base: nil)
49
+ Rocksky.post("app.rocksky.playlist.addSongs", { uri: uri, songs: songs },
50
+ base: base, token: token)
51
+ end
52
+
53
+ # Remove a song. An entry lives in the repo that added it, so only that repo
54
+ # can retract it.
55
+ def remove_track(uri, song_uri, token:, base: nil)
56
+ Rocksky.post("app.rocksky.playlist.removeTrack", { uri: uri, songUri: song_uri },
57
+ base: base, token: token)
58
+ end
59
+
60
+ # Delete a playlist and the caller's own entries. Owner only.
61
+ def remove(uri, token:, base: nil)
62
+ Rocksky.post("app.rocksky.playlist.removePlaylist", { uri: uri }, base: base, token: token)
63
+ end
64
+ end
65
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rocksky
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.1"
5
5
  end
data/lib/rocksky.rb CHANGED
@@ -27,10 +27,11 @@ module Rocksky
27
27
  "char* rocksky_top_tracks(const char*, unsigned int, unsigned int)",
28
28
  "char* rocksky_global_stats(const char*)",
29
29
  "char* rocksky_get(const char*, const char*, const char*, const char*)",
30
+ "char* rocksky_post(const char*, const char*, const char*, const char*)",
30
31
  "char* rocksky_update_seen(const char*, const char*, const char*)",
31
32
  "char* rocksky_library_get(const char*, const char*, const char*, const char*)",
32
33
  "char* rocksky_library_post(const char*, const char*, const char*, const char*)",
33
- "char* rocksky_match_song(const char*, const char*, const char*, const char*, const char*)",
34
+ "char* rocksky_match_song(const char*, const char*, const char*, const char*, const char*, const char*)",
34
35
  "char* rocksky_top_tracks_interval(const char*, unsigned int, unsigned int, const char*, unsigned int, const char*, const char*)",
35
36
  "char* rocksky_top_artists_interval(const char*, unsigned int, unsigned int, const char*, unsigned int, const char*, const char*)",
36
37
  "char* rocksky_song_hash(const char*, const char*, const char*)",
@@ -127,6 +128,51 @@ module Rocksky
127
128
  unwrap(C.rocksky_global_stats(base.to_s))
128
129
  end
129
130
 
131
+ # ---- catalog + feed reads with RSQL filtering ----
132
+ #
133
+ # +filter:+ accepts a Rocksky::Filter (see rocksky/filter.rb) or a raw RSQL
134
+ # String. Fields are Symbols, e.g. Rocksky::Filter.eq(:artist, "Daft Punk").
135
+
136
+ # The song catalog (app.rocksky.song.getSongs), optionally narrowed by
137
+ # +genre:+ and/or an RSQL +filter:+. Returns { "tracks" => [...] }.
138
+ def self.catalog_songs(limit: 50, offset: 0, genre: nil, filter: nil, base: nil)
139
+ get("app.rocksky.song.getSongs", catalog_params(limit, offset, genre, filter), base: base)
140
+ end
141
+
142
+ # The artist catalog (app.rocksky.artist.getArtists). Returns
143
+ # { "artists" => [...] }.
144
+ def self.catalog_artists(limit: 50, offset: 0, genre: nil, filter: nil, base: nil)
145
+ get("app.rocksky.artist.getArtists", catalog_params(limit, offset, genre, filter), base: base)
146
+ end
147
+
148
+ # The album catalog (app.rocksky.album.getAlbums). Returns
149
+ # { "albums" => [...] }.
150
+ def self.catalog_albums(limit: 50, offset: 0, genre: nil, filter: nil, base: nil)
151
+ get("app.rocksky.album.getAlbums", catalog_params(limit, offset, genre, filter), base: base)
152
+ end
153
+
154
+ # A social/global scrobbles feed (app.rocksky.scrobble.getScrobbles). Pass
155
+ # +did:+ to scope to an actor and +following: true+ for their follow graph.
156
+ # +filter:+ reaches the joined track/user/artist via dotted fields, e.g.
157
+ # Rocksky::Filter.eq(:"track.artist", "Daft Punk"). Returns
158
+ # { "scrobbles" => [...] }.
159
+ def self.scrobble_feed(did: nil, following: false, limit: 50, offset: 0, filter: nil, base: nil)
160
+ params = { limit: limit, offset: offset }
161
+ params[:did] = did unless did.nil?
162
+ params[:following] = following if following
163
+ params[:filter] = filter.to_s unless filter.nil?
164
+ get("app.rocksky.scrobble.getScrobbles", params, base: base)
165
+ end
166
+
167
+ # Shared param assembly for the three catalog reads (omit-nil idiom).
168
+ def self.catalog_params(limit, offset, genre, filter)
169
+ params = { limit: limit, offset: offset }
170
+ params[:genre] = genre unless genre.nil?
171
+ params[:filter] = filter.to_s unless filter.nil?
172
+ params
173
+ end
174
+ private_class_method :catalog_params
175
+
130
176
  # Universal read escape hatch — call any app.rocksky.* query by nsid. +params+
131
177
  # is a hash of string params; the whole read-query catalog is reachable here.
132
178
  #
@@ -138,6 +184,14 @@ module Rocksky
138
184
  unwrap(C.rocksky_get(base.to_s, nsid, JSON.generate(params), token.to_s))
139
185
  end
140
186
 
187
+ # Universal write escape hatch — call any app.rocksky.* procedure whose
188
+ # arguments ride the query string. Most are auth-gated, so pass +token+.
189
+ #
190
+ # Rocksky.post("app.rocksky.playlist.removePlaylist", { uri: uri }, token: tok)
191
+ def self.post(nsid, params = {}, base: nil, token: nil)
192
+ unwrap(C.rocksky_post(base.to_s, nsid, JSON.generate(params), token.to_s))
193
+ end
194
+
141
195
  # ---- notifications (auth-gated; +token+ required) ----
142
196
 
143
197
  # The authenticated viewer's unread-notification count. Returns
@@ -163,8 +217,10 @@ module Rocksky
163
217
  end
164
218
 
165
219
  # Resolve full canonical metadata for a bare title + artist (matchSong).
166
- def self.match_song(title, artist, mb_id: nil, isrc: nil, base: nil)
167
- unwrap(C.rocksky_match_song(base.to_s, title, artist, mb_id.to_s, isrc.to_s))
220
+ # +album+ steers the match toward that release (case-insensitive) so a
221
+ # remaster/live/single edition doesn't shadow the intended album.
222
+ def self.match_song(title, artist, album: nil, mb_id: nil, isrc: nil, base: nil)
223
+ unwrap(C.rocksky_match_song(base.to_s, title, artist, album.to_s, mb_id.to_s, isrc.to_s))
168
224
  end
169
225
 
170
226
  # Top tracks chart over a typed date window. +interval+ is +:all+, or a pair
@@ -301,5 +357,7 @@ module Rocksky
301
357
  end
302
358
  end
303
359
 
360
+ require_relative "rocksky/filter"
304
361
  require_relative "rocksky/library"
362
+ require_relative "rocksky/playlist"
305
363
  require_relative "rocksky/remote"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rocksky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rocksky
@@ -80,9 +80,11 @@ files:
80
80
  - README.md
81
81
  - exe/rocksky-console
82
82
  - lib/rocksky.rb
83
+ - lib/rocksky/filter.rb
83
84
  - lib/rocksky/library.rb
84
85
  - lib/rocksky/manifest.json
85
86
  - lib/rocksky/native.rb
87
+ - lib/rocksky/playlist.rb
86
88
  - lib/rocksky/remote.rb
87
89
  - lib/rocksky/version.rb
88
90
  - rocksky.gemspec