notu 3.0.0 → 5.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 +4 -4
- data/README.mdown +5 -5
- data/VERSION +1 -1
- data/lib/notu/api.rb +25 -0
- data/lib/notu/json_document.rb +11 -0
- data/lib/notu/loved_tracks.rb +18 -10
- data/lib/notu/recent_tracks.rb +34 -0
- data/lib/notu/top_tracks.rb +47 -0
- data/lib/notu/user_api.rb +30 -0
- data/lib/notu.rb +5 -7
- data/notu.gemspec +1 -2
- metadata +8 -29
- data/lib/notu/html_document.rb +0 -19
- data/lib/notu/library.rb +0 -59
- data/lib/notu/listing.rb +0 -32
- data/lib/notu/most_played_tracks.rb +0 -54
- data/lib/notu/played_tracks.rb +0 -26
- data/lib/notu/unknown_username_error.rb +0 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 43f1b5f5623c11b9b3ecf2bb19a64a65681ff6b4414a06eb26967d4655095d27
|
4
|
+
data.tar.gz: 4b581b8a75c6211194c3d0564edbd11c88b83fe66623fc1890fe3af7b54e148d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e6fae0ce54a0bce085f50531e859e9438a90745884271222600559b90dafcd39a5d0cbf270d80f959703f6108d268a1fe314f96d788a40ad818d26984f5c5c98
|
7
|
+
data.tar.gz: f2c0d4527a7eb783dcf728fa490d6a89669257ff3b628b9287ba54fd135f6ecf293a6fc2f5ab69e06314d176cd561586ead4938c057e087dbe3edd9e387b7aa4
|
data/README.mdown
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Notu
|
2
2
|
|
3
|
-
API to get Last.fm tracks (
|
3
|
+
API to get Last.fm tracks (top, loved, etc.).
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
@@ -15,17 +15,17 @@ Then, just run `bundle install`.
|
|
15
15
|
## Example
|
16
16
|
|
17
17
|
```ruby
|
18
|
-
|
18
|
+
user_api = Notu::UserApi.new(username: 'johndoe')
|
19
19
|
|
20
|
-
|
20
|
+
user_api.loved_tracks.each do |track|
|
21
21
|
puts track.artist
|
22
22
|
end
|
23
23
|
|
24
|
-
|
24
|
+
user_api.top_tracks(period: '3month').each do |track|
|
25
25
|
puts "#{track.artist}: #{track.plays_count}"
|
26
26
|
end
|
27
27
|
|
28
|
-
|
28
|
+
user_api.recent_tracks.each do |track|
|
29
29
|
puts track.title
|
30
30
|
end
|
31
31
|
```
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
|
1
|
+
5.0.0
|
data/lib/notu/api.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
module Notu
|
2
|
+
|
3
|
+
class Api
|
4
|
+
|
5
|
+
DEFAULT_API_KEY = '91f5d6a201de58e0c0a0d858573dddf0'.freeze
|
6
|
+
FORMAT = 'json'.freeze
|
7
|
+
HOST = 'ws.audioscrobbler.com'.freeze
|
8
|
+
VERSION = '2.0'.freeze
|
9
|
+
|
10
|
+
attr_reader :api_key
|
11
|
+
|
12
|
+
def initialize(api_key: DEFAULT_API_KEY)
|
13
|
+
@api_key = api_key.try(:squish).presence || raise(Error.new('API key must be specified'))
|
14
|
+
end
|
15
|
+
|
16
|
+
def url(params = {})
|
17
|
+
params = (params || {}).symbolize_keys
|
18
|
+
params.merge!(api_key:, format: FORMAT)
|
19
|
+
query_string = params.map { |name, value| "#{CGI.escape(name.to_s)}=#{CGI.escape(value.to_s)}" }.join('&')
|
20
|
+
"https://#{HOST}/#{VERSION}?#{query_string}"
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
data/lib/notu/loved_tracks.rb
CHANGED
@@ -2,25 +2,33 @@ module Notu
|
|
2
2
|
|
3
3
|
class LovedTracks
|
4
4
|
|
5
|
-
include
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
attr_reader :user_api
|
8
|
+
|
9
|
+
def initialize(user_api)
|
10
|
+
raise ArgumentError.new("#{self.class}#user_api must be specified") unless user_api
|
11
|
+
@user_api = user_api
|
12
|
+
end
|
6
13
|
|
7
14
|
def each
|
8
15
|
return unless block_given?
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
16
|
+
pages_count = nil
|
17
|
+
page = 1
|
18
|
+
loop do
|
19
|
+
json = JsonDocument.get(user_api.url(limit: 50, method: 'user.getLovedTracks', page:))
|
20
|
+
pages_count = json['lovedtracks']['@attr']['totalPages'].to_i
|
21
|
+
json['lovedtracks']['track'].each do |track_json|
|
22
|
+
artist = track_json['artist']['name'] || next
|
23
|
+
title = track_json['name'] || next
|
14
24
|
yield(Track.new(artist:, title:))
|
15
25
|
end
|
26
|
+
page += 1
|
27
|
+
break if page > pages_count
|
16
28
|
end
|
17
29
|
nil
|
18
30
|
end
|
19
31
|
|
20
|
-
def path
|
21
|
-
'loved'
|
22
|
-
end
|
23
|
-
|
24
32
|
end
|
25
33
|
|
26
34
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Notu
|
2
|
+
|
3
|
+
class RecentTracks
|
4
|
+
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
attr_reader :user_api
|
8
|
+
|
9
|
+
def initialize(user_api)
|
10
|
+
raise ArgumentError.new("#{self.class}#user_api must be specified") unless user_api
|
11
|
+
@user_api = user_api
|
12
|
+
end
|
13
|
+
|
14
|
+
def each
|
15
|
+
return unless block_given?
|
16
|
+
pages_count = nil
|
17
|
+
page = 1
|
18
|
+
loop do
|
19
|
+
json = JsonDocument.get(user_api.url(limit: 50, method: 'user.getRecentTracks', page:))
|
20
|
+
pages_count = json['recenttracks']['@attr']['totalPages'].to_i
|
21
|
+
json['recenttracks']['track'].each do |track_json|
|
22
|
+
artist = track_json['artist']['#text'] || next
|
23
|
+
title = track_json['name'] || next
|
24
|
+
yield(Track.new(artist:, title:))
|
25
|
+
end
|
26
|
+
page += 1
|
27
|
+
break if page > pages_count
|
28
|
+
end
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Notu
|
2
|
+
|
3
|
+
class TopTracks
|
4
|
+
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
PERIODS = %w(overall 7day 1month 3month 6month 12month).freeze
|
8
|
+
|
9
|
+
attr_reader :period, :user_api
|
10
|
+
|
11
|
+
def initialize(user_api, options = {})
|
12
|
+
raise ArgumentError.new("#{self.class}#user_api must be specified") unless user_api
|
13
|
+
@user_api = user_api
|
14
|
+
options = options.symbolize_keys.reverse_merge(period: PERIODS.first)
|
15
|
+
self.period = options[:period]
|
16
|
+
end
|
17
|
+
|
18
|
+
def each
|
19
|
+
return unless block_given?
|
20
|
+
pages_count = nil
|
21
|
+
page = 1
|
22
|
+
loop do
|
23
|
+
json = JsonDocument.get(user_api.url(limit: 50, method: 'user.getTopTracks', page:))
|
24
|
+
pages_count = json['toptracks']['@attr']['totalPages'].to_i
|
25
|
+
json['toptracks']['track'].each do |track_json|
|
26
|
+
artist = track_json['artist']['name'] || next
|
27
|
+
title = track_json['name'] || next
|
28
|
+
plays_count = track_json['playcount'] || next
|
29
|
+
yield(Track.new(artist:, plays_count:, title:))
|
30
|
+
end
|
31
|
+
page += 1
|
32
|
+
break if page > pages_count
|
33
|
+
end
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def period=(value)
|
40
|
+
string_value = value.to_s
|
41
|
+
raise ArgumentError.new("#{self.class.name}#period is invalid: #{value.inspect}") unless PERIODS.include?(string_value)
|
42
|
+
@period = string_value
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Notu
|
2
|
+
|
3
|
+
class UserApi < Api
|
4
|
+
|
5
|
+
attr_reader :username
|
6
|
+
|
7
|
+
def initialize(username:, api_key: DEFAULT_API_KEY)
|
8
|
+
super(api_key:)
|
9
|
+
@username = username.try(:squish).presence || raise(Error.new('Username must be specified'))
|
10
|
+
end
|
11
|
+
|
12
|
+
def loved_tracks
|
13
|
+
LovedTracks.new(self)
|
14
|
+
end
|
15
|
+
|
16
|
+
def recent_tracks
|
17
|
+
RecentTracks.new(self)
|
18
|
+
end
|
19
|
+
|
20
|
+
def top_tracks(options = {})
|
21
|
+
TopTracks.new(self, options)
|
22
|
+
end
|
23
|
+
|
24
|
+
def url(params = {})
|
25
|
+
super((params || {}).symbolize_keys.merge(user: username))
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
data/lib/notu.rb
CHANGED
@@ -3,7 +3,6 @@ require 'active_support/core_ext'
|
|
3
3
|
require 'byebug' if ENV['DEBUGGER']
|
4
4
|
require 'cgi'
|
5
5
|
require 'net/https'
|
6
|
-
require 'nokogiri'
|
7
6
|
|
8
7
|
lib_path = "#{__dir__}/notu"
|
9
8
|
|
@@ -18,12 +17,11 @@ require "#{lib_path}/error"
|
|
18
17
|
require "#{lib_path}/network_error"
|
19
18
|
require "#{lib_path}/parse_error"
|
20
19
|
|
21
|
-
require "#{lib_path}/
|
20
|
+
require "#{lib_path}/api"
|
22
21
|
require "#{lib_path}/http_download"
|
23
|
-
require "#{lib_path}/
|
24
|
-
require "#{lib_path}/listing"
|
22
|
+
require "#{lib_path}/json_document"
|
25
23
|
require "#{lib_path}/loved_tracks"
|
26
|
-
require "#{lib_path}/
|
27
|
-
require "#{lib_path}/
|
24
|
+
require "#{lib_path}/recent_tracks"
|
25
|
+
require "#{lib_path}/top_tracks"
|
28
26
|
require "#{lib_path}/track"
|
29
|
-
require "#{lib_path}/
|
27
|
+
require "#{lib_path}/user_api"
|
data/notu.gemspec
CHANGED
@@ -6,7 +6,7 @@ Gem::Specification.new do |s|
|
|
6
6
|
s.email = 'al@alweb.org'
|
7
7
|
s.homepage = 'https://github.com/alexistoulotte/notu'
|
8
8
|
s.summary = 'API for Last.fm'
|
9
|
-
s.description = 'API to get Last.fm tracks (
|
9
|
+
s.description = 'API to get Last.fm tracks (top, loved, etc.)'
|
10
10
|
s.license = 'MIT'
|
11
11
|
|
12
12
|
s.files = %x(git ls-files | grep -vE '^(spec/|test/|\\.|Gemfile|Rakefile)').split("\n")
|
@@ -16,7 +16,6 @@ Gem::Specification.new do |s|
|
|
16
16
|
s.required_ruby_version = '>= 3.1.0'
|
17
17
|
|
18
18
|
s.add_dependency 'activesupport', '>= 7.0.0', '< 8.0.0'
|
19
|
-
s.add_dependency 'nokogiri', '>= 1.6.0', '< 1.14.0'
|
20
19
|
|
21
20
|
s.add_development_dependency 'byebug', '>= 3.2.0', '< 12.0.0'
|
22
21
|
s.add_development_dependency 'rake', '>= 10.3.0', '< 14.0.0'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: notu
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 5.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alexis Toulotte
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-01-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -30,26 +30,6 @@ dependencies:
|
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
32
|
version: 8.0.0
|
33
|
-
- !ruby/object:Gem::Dependency
|
34
|
-
name: nokogiri
|
35
|
-
requirement: !ruby/object:Gem::Requirement
|
36
|
-
requirements:
|
37
|
-
- - ">="
|
38
|
-
- !ruby/object:Gem::Version
|
39
|
-
version: 1.6.0
|
40
|
-
- - "<"
|
41
|
-
- !ruby/object:Gem::Version
|
42
|
-
version: 1.14.0
|
43
|
-
type: :runtime
|
44
|
-
prerelease: false
|
45
|
-
version_requirements: !ruby/object:Gem::Requirement
|
46
|
-
requirements:
|
47
|
-
- - ">="
|
48
|
-
- !ruby/object:Gem::Version
|
49
|
-
version: 1.6.0
|
50
|
-
- - "<"
|
51
|
-
- !ruby/object:Gem::Version
|
52
|
-
version: 1.14.0
|
53
33
|
- !ruby/object:Gem::Dependency
|
54
34
|
name: byebug
|
55
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -210,7 +190,7 @@ dependencies:
|
|
210
190
|
- - "<"
|
211
191
|
- !ruby/object:Gem::Version
|
212
192
|
version: 4.0.0
|
213
|
-
description: API to get Last.fm tracks (
|
193
|
+
description: API to get Last.fm tracks (top, loved, etc.)
|
214
194
|
email: al@alweb.org
|
215
195
|
executables: []
|
216
196
|
extensions: []
|
@@ -220,18 +200,17 @@ files:
|
|
220
200
|
- README.mdown
|
221
201
|
- VERSION
|
222
202
|
- lib/notu.rb
|
203
|
+
- lib/notu/api.rb
|
223
204
|
- lib/notu/error.rb
|
224
|
-
- lib/notu/html_document.rb
|
225
205
|
- lib/notu/http_download.rb
|
226
|
-
- lib/notu/
|
227
|
-
- lib/notu/listing.rb
|
206
|
+
- lib/notu/json_document.rb
|
228
207
|
- lib/notu/loved_tracks.rb
|
229
|
-
- lib/notu/most_played_tracks.rb
|
230
208
|
- lib/notu/network_error.rb
|
231
209
|
- lib/notu/parse_error.rb
|
232
|
-
- lib/notu/
|
210
|
+
- lib/notu/recent_tracks.rb
|
211
|
+
- lib/notu/top_tracks.rb
|
233
212
|
- lib/notu/track.rb
|
234
|
-
- lib/notu/
|
213
|
+
- lib/notu/user_api.rb
|
235
214
|
- notu.gemspec
|
236
215
|
homepage: https://github.com/alexistoulotte/notu
|
237
216
|
licenses:
|
data/lib/notu/html_document.rb
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
module Notu
|
2
|
-
|
3
|
-
module HtmlDocument
|
4
|
-
|
5
|
-
def self.get(url, options = {})
|
6
|
-
parse(HttpDownload.get(url, options))
|
7
|
-
end
|
8
|
-
|
9
|
-
def self.parse(data)
|
10
|
-
data = data.gsub(/ /i, ' ').gsub(/\s+/, ' ')
|
11
|
-
document = Nokogiri::HTML.parse(data, nil, 'UTF-8')
|
12
|
-
raise ParseError.new('Invalid HTML document') if (document / 'head').empty?
|
13
|
-
document
|
14
|
-
end
|
15
|
-
private_class_method :parse
|
16
|
-
|
17
|
-
end
|
18
|
-
|
19
|
-
end
|
data/lib/notu/library.rb
DELETED
@@ -1,59 +0,0 @@
|
|
1
|
-
module Notu
|
2
|
-
|
3
|
-
class Library
|
4
|
-
|
5
|
-
HOST = 'www.last.fm'.freeze
|
6
|
-
|
7
|
-
attr_reader :username
|
8
|
-
|
9
|
-
def initialize(options = {})
|
10
|
-
@semaphore = Mutex.new
|
11
|
-
options = options.symbolize_keys
|
12
|
-
self.username = options[:username]
|
13
|
-
end
|
14
|
-
|
15
|
-
def loved_tracks
|
16
|
-
LovedTracks.new(self)
|
17
|
-
end
|
18
|
-
|
19
|
-
def most_played_tracks(options = {})
|
20
|
-
MostPlayedTracks.new(self, options)
|
21
|
-
end
|
22
|
-
|
23
|
-
def played_tracks
|
24
|
-
PlayedTracks.new(self)
|
25
|
-
end
|
26
|
-
|
27
|
-
def url(options = {})
|
28
|
-
options = options.symbolize_keys
|
29
|
-
path = options[:path].presence
|
30
|
-
query = options[:query].presence
|
31
|
-
query = options[:query].map { |name, value| "#{CGI.escape(name.to_s)}=#{CGI.escape(value.to_s)}" }.join('&') if options[:query].is_a?(Hash)
|
32
|
-
"https://#{HOST}/user/#{username}".tap do |url|
|
33
|
-
if path.present?
|
34
|
-
url << '/' unless path.starts_with?('/')
|
35
|
-
url << path
|
36
|
-
end
|
37
|
-
if query.present?
|
38
|
-
url << '?' << query
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
42
|
-
|
43
|
-
private
|
44
|
-
|
45
|
-
def username=(value)
|
46
|
-
@semaphore.synchronize do
|
47
|
-
@username = value.to_s.strip.downcase
|
48
|
-
raise UnknownUsernameError.new(value) if username !~ /^[a-z0-9_]+$/
|
49
|
-
begin
|
50
|
-
HtmlDocument.get(url)
|
51
|
-
rescue
|
52
|
-
raise UnknownUsernameError.new(value)
|
53
|
-
end
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
end
|
58
|
-
|
59
|
-
end
|
data/lib/notu/listing.rb
DELETED
@@ -1,32 +0,0 @@
|
|
1
|
-
module Notu
|
2
|
-
|
3
|
-
module Listing
|
4
|
-
|
5
|
-
include Enumerable
|
6
|
-
|
7
|
-
attr_reader :library
|
8
|
-
|
9
|
-
def initialize(library)
|
10
|
-
raise ArgumentError.new("#{self.class}#library must be a library, #{library.inspect} given") unless library.is_a?(Library)
|
11
|
-
@library = library
|
12
|
-
end
|
13
|
-
|
14
|
-
def page_urls
|
15
|
-
(1..pages_count).map do |index|
|
16
|
-
library.url(path:, query: params.merge('page' => index))
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
|
-
def pages_count
|
21
|
-
document = HtmlDocument.get(library.url(path:, query: params))
|
22
|
-
[1, (document / 'ul.pagination-list li.pagination-page').text.split(/\s+/).map(&:to_i)].flatten.compact.max
|
23
|
-
end
|
24
|
-
|
25
|
-
def params
|
26
|
-
# to be overriden
|
27
|
-
{}
|
28
|
-
end
|
29
|
-
|
30
|
-
end
|
31
|
-
|
32
|
-
end
|
@@ -1,54 +0,0 @@
|
|
1
|
-
module Notu
|
2
|
-
|
3
|
-
class MostPlayedTracks
|
4
|
-
|
5
|
-
include Listing
|
6
|
-
|
7
|
-
PERIODS = {
|
8
|
-
'7 days' => 'LAST_7_DAYS',
|
9
|
-
'30 days' => 'LAST_30_DAYS',
|
10
|
-
'90 days' => 'LAST_90_DAYS',
|
11
|
-
'365 days' => 'LAST_365_DAYS',
|
12
|
-
'Overall' => '',
|
13
|
-
}.freeze
|
14
|
-
|
15
|
-
attr_reader :period
|
16
|
-
|
17
|
-
def initialize(library, options = {})
|
18
|
-
super(library)
|
19
|
-
options = options.stringify_keys.reverse_merge('period' => PERIODS.keys.first)
|
20
|
-
self.period = options['period']
|
21
|
-
end
|
22
|
-
|
23
|
-
def each
|
24
|
-
return unless block_given?
|
25
|
-
page_urls.each do |url|
|
26
|
-
document = HtmlDocument.get(url)
|
27
|
-
(document / 'table.chartlist tbody tr').each do |element|
|
28
|
-
artist = (element / 'td.chartlist-artist a').first.try(:text) || next
|
29
|
-
title = (element / 'td.chartlist-name a').first.try(:text) || next
|
30
|
-
plays_count = (element / 'td.chartlist-bar .chartlist-count-bar-value').text.gsub(/[^\d]/, '').presence || next
|
31
|
-
yield(Track.new(artist:, plays_count:, title:))
|
32
|
-
end
|
33
|
-
end
|
34
|
-
nil
|
35
|
-
end
|
36
|
-
|
37
|
-
def params
|
38
|
-
{ 'date_preset' => PERIODS[period] }
|
39
|
-
end
|
40
|
-
|
41
|
-
def path
|
42
|
-
'library/tracks'
|
43
|
-
end
|
44
|
-
|
45
|
-
private
|
46
|
-
|
47
|
-
def period=(value)
|
48
|
-
raise ArgumentError.new("Notu::MostPlayedTracks#period is invalid: #{value.inspect}") unless PERIODS.key?(value.to_s)
|
49
|
-
@period = value.to_s
|
50
|
-
end
|
51
|
-
|
52
|
-
end
|
53
|
-
|
54
|
-
end
|
data/lib/notu/played_tracks.rb
DELETED
@@ -1,26 +0,0 @@
|
|
1
|
-
module Notu
|
2
|
-
|
3
|
-
class PlayedTracks
|
4
|
-
|
5
|
-
include Listing
|
6
|
-
|
7
|
-
def each
|
8
|
-
return unless block_given?
|
9
|
-
page_urls.each do |url|
|
10
|
-
document = HtmlDocument.get(url)
|
11
|
-
(document / 'table.chartlist tbody tr').each do |element|
|
12
|
-
artist = (element / 'td.chartlist-artist a').first.try(:text) || next
|
13
|
-
title = (element / 'td.chartlist-name a').first.try(:text) || next
|
14
|
-
yield(Track.new(artist:, title:))
|
15
|
-
end
|
16
|
-
end
|
17
|
-
nil
|
18
|
-
end
|
19
|
-
|
20
|
-
def path
|
21
|
-
'library'
|
22
|
-
end
|
23
|
-
|
24
|
-
end
|
25
|
-
|
26
|
-
end
|