good_audible_story_sync 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/bin/good-audible-story-sync +6 -0
- data/lib/good_audible_story_sync/audible/auth.rb +262 -0
- data/lib/good_audible_story_sync/audible/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/audible/client.rb +220 -0
- data/lib/good_audible_story_sync/audible/library.rb +318 -0
- data/lib/good_audible_story_sync/audible/library_item.rb +213 -0
- data/lib/good_audible_story_sync/audible/user_profile.rb +39 -0
- data/lib/good_audible_story_sync/audible.rb +13 -0
- data/lib/good_audible_story_sync/database/audible_books.rb +66 -0
- data/lib/good_audible_story_sync/database/client.rb +70 -0
- data/lib/good_audible_story_sync/database/credentials.rb +48 -0
- data/lib/good_audible_story_sync/database/goodreads_books.rb +60 -0
- data/lib/good_audible_story_sync/database/storygraph_books.rb +74 -0
- data/lib/good_audible_story_sync/database/sync_times.rb +52 -0
- data/lib/good_audible_story_sync/database.rb +16 -0
- data/lib/good_audible_story_sync/goodreads/auth.rb +137 -0
- data/lib/good_audible_story_sync/goodreads/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/goodreads/book.rb +171 -0
- data/lib/good_audible_story_sync/goodreads/client.rb +98 -0
- data/lib/good_audible_story_sync/goodreads/library.rb +149 -0
- data/lib/good_audible_story_sync/goodreads.rb +12 -0
- data/lib/good_audible_story_sync/input_loop.rb +214 -0
- data/lib/good_audible_story_sync/options.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/auth.rb +91 -0
- data/lib/good_audible_story_sync/storygraph/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/book.rb +261 -0
- data/lib/good_audible_story_sync/storygraph/client.rb +247 -0
- data/lib/good_audible_story_sync/storygraph/library.rb +183 -0
- data/lib/good_audible_story_sync/storygraph/look_up_book_flow.rb +172 -0
- data/lib/good_audible_story_sync/storygraph/mark_finished_flow.rb +201 -0
- data/lib/good_audible_story_sync/storygraph.rb +14 -0
- data/lib/good_audible_story_sync/util/cipher.rb +43 -0
- data/lib/good_audible_story_sync/util/keychain.rb +32 -0
- data/lib/good_audible_story_sync/util.rb +92 -0
- data/lib/good_audible_story_sync/version.rb +6 -0
- data/lib/good_audible_story_sync.rb +14 -0
- metadata +80 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
# encoding: utf-8
|
|
4
|
+
|
|
5
|
+
require "date"
|
|
6
|
+
require "httparty"
|
|
7
|
+
|
|
8
|
+
module GoodAudibleStorySync
|
|
9
|
+
module Audible
|
|
10
|
+
class Client
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
class NotAuthenticatedError < StandardError; end
|
|
14
|
+
|
|
15
|
+
US_DOMAIN = "com"
|
|
16
|
+
|
|
17
|
+
sig { params(auth: Auth, options: Options, credentials_db: Database::Credentials).void }
|
|
18
|
+
def initialize(auth:, options:, credentials_db:)
|
|
19
|
+
@auth = auth
|
|
20
|
+
@api_url = "https://api.audible.#{US_DOMAIN}"
|
|
21
|
+
@have_attempted_token_refresh = T.let(false, T::Boolean)
|
|
22
|
+
@credentials_db = credentials_db
|
|
23
|
+
@options = options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(UserProfile) }
|
|
27
|
+
def get_user_profile
|
|
28
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
29
|
+
|
|
30
|
+
url = "#{@api_url}/user/profile"
|
|
31
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
32
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
33
|
+
data = make_json_request(make_request, action: "get user profile")
|
|
34
|
+
UserProfile.new(data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# https://audible.readthedocs.io/en/master/misc/external_api.html#get--1.0-stats-status-finished
|
|
38
|
+
sig { returns T::Hash[String, DateTime] }
|
|
39
|
+
def get_finish_times_by_asin
|
|
40
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
41
|
+
|
|
42
|
+
url = "#{@api_url}/1.0/stats/status/finished"
|
|
43
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
44
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
45
|
+
data = make_json_request(make_request, action: "get finish times by ASIN")
|
|
46
|
+
finished_items = (data["mark_as_finished_status_list"] || [])
|
|
47
|
+
.select { |item| item["is_marked_as_finished"] }
|
|
48
|
+
result = T.let({}, T::Hash[String, DateTime])
|
|
49
|
+
finished_items.each do |item|
|
|
50
|
+
asin = item["asin"]
|
|
51
|
+
timestamp = DateTime.parse(item["event_timestamp"])
|
|
52
|
+
if !result.key?(asin) || timestamp > result[asin]
|
|
53
|
+
result[asin] = timestamp
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# https://audible.readthedocs.io/en/master/misc/external_api.html#get--1.0-stats-aggregates
|
|
60
|
+
sig { returns Hash }
|
|
61
|
+
def get_aggregate_stats
|
|
62
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
63
|
+
|
|
64
|
+
params = {
|
|
65
|
+
"locale" => "en_US",
|
|
66
|
+
"response_groups" => "total_listening_stats",
|
|
67
|
+
"store" => "Audible",
|
|
68
|
+
}
|
|
69
|
+
url = "#{@api_url}/1.0/stats/aggregates?#{URI.encode_www_form(params)}"
|
|
70
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
71
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
72
|
+
make_json_request(make_request, action: "get aggregate stats")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# https://audible.readthedocs.io/en/master/misc/external_api.html#get--1.0-collections
|
|
76
|
+
sig { returns Hash }
|
|
77
|
+
def get_collections
|
|
78
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
79
|
+
|
|
80
|
+
url = "#{@api_url}/1.0/collections"
|
|
81
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
82
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
83
|
+
make_json_request(make_request, action: "get collections")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# https://audible.readthedocs.io/en/master/misc/external_api.html#get--1.0-library-(string-asin)
|
|
87
|
+
sig { params(asin: String).returns(LibraryItem) }
|
|
88
|
+
def get_library_item(asin:)
|
|
89
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
90
|
+
|
|
91
|
+
params = {
|
|
92
|
+
"response_groups" => "contributors,media,price,product_attrs,product_desc," \
|
|
93
|
+
"product_details,product_extended_attrs,product_plan_details,product_plans," \
|
|
94
|
+
"rating,sample,sku,series,reviews,ws4v,origin,relationships,review_attrs," \
|
|
95
|
+
"categories,badge_types,category_ladders,claim_code_url,is_downloaded," \
|
|
96
|
+
"is_finished,is_returnable,origin_asin,pdf_url,percent_complete,periodicals," \
|
|
97
|
+
"provided_review",
|
|
98
|
+
}
|
|
99
|
+
url = "#{@api_url}/1.0/library/#{asin}?#{URI.encode_www_form(params)}"
|
|
100
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
101
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
102
|
+
data = make_json_request(make_request, action: "get library item #{asin}")
|
|
103
|
+
LibraryItem.new(data["item"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# https://audible.readthedocs.io/en/latest/misc/external_api.html#get--1.0-orders
|
|
107
|
+
def get_orders
|
|
108
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
109
|
+
|
|
110
|
+
url = "#{@api_url}/1.0/orders"
|
|
111
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
112
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
113
|
+
make_json_request(make_request, action: "get orders")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# https://audible.readthedocs.io/en/master/misc/external_api.html#library
|
|
117
|
+
sig { params(page: Integer, per_page: Integer).returns([Integer, T::Array[LibraryItem]]) }
|
|
118
|
+
def get_library_page(page: 1, per_page: 50)
|
|
119
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
120
|
+
|
|
121
|
+
params = {
|
|
122
|
+
"sort_by" => "-PurchaseDate",
|
|
123
|
+
"include_pending" => "false",
|
|
124
|
+
"num_results" => per_page,
|
|
125
|
+
"page" => page,
|
|
126
|
+
"response_groups" => "contributors,is_finished,listening_status,percent_complete,product_attrs,product_desc,product_details",
|
|
127
|
+
}
|
|
128
|
+
url = "#{@api_url}/1.0/library?#{URI.encode_www_form(params)}"
|
|
129
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
130
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
131
|
+
total_count = T.let(0, Integer)
|
|
132
|
+
process_headers = ->(headers) { total_count = headers["total-count"].to_i }
|
|
133
|
+
library_data = make_json_request(make_request, action: "get library",
|
|
134
|
+
process_headers: process_headers)
|
|
135
|
+
page_items = library_data["items"].map { |data| LibraryItem.new(data) }
|
|
136
|
+
[total_count, page_items]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# https://audible.readthedocs.io/en/latest/misc/external_api.html#get--1.0-badges-progress
|
|
140
|
+
def get_badge_progress
|
|
141
|
+
raise NotAuthenticatedError unless @auth.access_token
|
|
142
|
+
|
|
143
|
+
params = { locale: "en_US", store: "Audible" }
|
|
144
|
+
url = "#{@api_url}/1.0/badges/progress?#{URI.encode_www_form(params)}"
|
|
145
|
+
puts "#{Util::INFO_EMOJI} GET #{url}"
|
|
146
|
+
make_request = -> { HTTParty.get(url, headers: headers) }
|
|
147
|
+
make_json_request(make_request, action: "get badge progress")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sig { returns Library }
|
|
151
|
+
def get_all_library_pages
|
|
152
|
+
per_page = 999
|
|
153
|
+
all_items = T.let([], T::Array[LibraryItem])
|
|
154
|
+
total_count, page_items = get_library_page(page: 1, per_page: per_page)
|
|
155
|
+
puts "#{Util::TAB}Loaded #{page_items.size} of #{total_count} item(s) in library"
|
|
156
|
+
all_items.concat(page_items)
|
|
157
|
+
total_pages = (total_count.to_f / per_page).ceil
|
|
158
|
+
(2..total_pages).each do |page|
|
|
159
|
+
_, page_items = get_library_page(page: page, per_page: per_page)
|
|
160
|
+
puts "#{Util::TAB}Loaded #{all_items.size + page_items.size} of #{total_count} " \
|
|
161
|
+
"item(s) in library"
|
|
162
|
+
all_items.concat(page_items)
|
|
163
|
+
end
|
|
164
|
+
Library.new(items: all_items)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
sig do
|
|
170
|
+
params(
|
|
171
|
+
make_request: T.proc.returns(HTTParty::Response),
|
|
172
|
+
action: String,
|
|
173
|
+
process_headers: T.nilable(T.proc.params(arg0: Hash).void),
|
|
174
|
+
).returns(T.untyped)
|
|
175
|
+
end
|
|
176
|
+
def make_json_request(make_request, action:, process_headers: nil)
|
|
177
|
+
response = make_request.call
|
|
178
|
+
process_headers&.call(response.headers)
|
|
179
|
+
handle_json_response(action: action, response: response)
|
|
180
|
+
rescue Auth::InvalidTokenError, Auth::ForbiddenError
|
|
181
|
+
if @have_attempted_token_refresh
|
|
182
|
+
puts "#{Util::ERROR_EMOJI} Invalid token persists after refreshing it, giving up"
|
|
183
|
+
nil
|
|
184
|
+
else
|
|
185
|
+
refresh_token
|
|
186
|
+
response = make_request.call
|
|
187
|
+
process_headers&.call(response.headers)
|
|
188
|
+
handle_json_response(action: action, response: response)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
sig { params(action: String, response: HTTParty::Response).returns(T.untyped) }
|
|
193
|
+
def handle_json_response(action:, response:)
|
|
194
|
+
Auth.handle_http_error(action: action, response: response) unless response.code == 200
|
|
195
|
+
JSON.parse(response.body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { returns(T::Hash[String, String]) }
|
|
199
|
+
def headers
|
|
200
|
+
{ "Authorization" => "Bearer #{@auth.access_token}" }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
sig { void }
|
|
204
|
+
def refresh_token
|
|
205
|
+
puts "#{Util::INFO_EMOJI} Refreshing Audible access token..."
|
|
206
|
+
new_access_token, new_expires = Auth.refresh_token(@auth.refresh_token)
|
|
207
|
+
|
|
208
|
+
if new_access_token.size > 0
|
|
209
|
+
@auth.access_token = new_access_token
|
|
210
|
+
@auth.expires = new_expires
|
|
211
|
+
@auth.save_to_database(@credentials_db)
|
|
212
|
+
@have_attempted_token_refresh = true
|
|
213
|
+
else
|
|
214
|
+
puts "#{Util::ERROR_EMOJI} Failed to refresh Audible access token, giving up"
|
|
215
|
+
raise NotAuthenticatedError
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
# encoding: utf-8
|
|
4
|
+
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
module Audible
|
|
9
|
+
class Library
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
SYNC_TIME_KEY = "audible_library"
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(client: Client, options: Options, db_client: Database::Client).returns(Library)
|
|
16
|
+
end
|
|
17
|
+
def self.load_with_finish_times(client:, options:, db_client:)
|
|
18
|
+
load_finish_times = T.let(false, T::Boolean)
|
|
19
|
+
library_cache_last_modified = db_client.sync_times.find(SYNC_TIME_KEY)&.to_time
|
|
20
|
+
library_is_cached = !library_cache_last_modified.nil?
|
|
21
|
+
if library_is_cached
|
|
22
|
+
puts "#{Util::INFO_EMOJI} Audible library last cached at #{Util.pretty_time(library_cache_last_modified)}"
|
|
23
|
+
end
|
|
24
|
+
should_refresh_library = library_cache_last_modified &&
|
|
25
|
+
library_cache_last_modified > options.refresh_cutoff_time
|
|
26
|
+
|
|
27
|
+
if library_is_cached && should_refresh_library
|
|
28
|
+
library = load_from_database(db_client.audible_books)
|
|
29
|
+
load_finish_times = !library.any_finished_time_loaded?
|
|
30
|
+
else
|
|
31
|
+
puts "#{Util::INFO_EMOJI} Updating Audible library cache..." if library_is_cached
|
|
32
|
+
library = client.get_all_library_pages
|
|
33
|
+
load_finish_times = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if load_finish_times
|
|
37
|
+
finish_times_by_asin = client.get_finish_times_by_asin
|
|
38
|
+
library.populate_finish_times(finish_times_by_asin)
|
|
39
|
+
library.save_to_database(db_client)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
library
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(books_db: Database::AudibleBooks).returns(Library) }
|
|
46
|
+
def self.load_from_database(books_db)
|
|
47
|
+
library = new
|
|
48
|
+
library.load_from_database(books_db)
|
|
49
|
+
library
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns T::Array[LibraryItem] }
|
|
53
|
+
attr_reader :items
|
|
54
|
+
|
|
55
|
+
sig { params(items: T::Array[LibraryItem]).void }
|
|
56
|
+
def initialize(items: [])
|
|
57
|
+
@items = items
|
|
58
|
+
@loaded_from_database = T.let(false, T::Boolean)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { params(isbn: String).returns(T.nilable(LibraryItem)) }
|
|
62
|
+
def find_by_isbn(isbn)
|
|
63
|
+
items.detect { |library_item| library_item.isbn == isbn }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { returns T::Hash[String, Date] }
|
|
67
|
+
def finish_dates_by_isbn
|
|
68
|
+
finished_items.each_with_object({}) do |library_item, result|
|
|
69
|
+
isbn = library_item.isbn
|
|
70
|
+
finish_time = library_item.finished_at
|
|
71
|
+
if isbn && !isbn.empty? && finish_time
|
|
72
|
+
result[isbn] = finish_time.to_date
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { returns Integer }
|
|
78
|
+
def total_items
|
|
79
|
+
items.size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(db_client: Database::Client).returns(Integer) }
|
|
83
|
+
def save_to_database(db_client)
|
|
84
|
+
puts "#{Util::SAVE_EMOJI} Caching Audible library in database..."
|
|
85
|
+
total_saved = 0
|
|
86
|
+
books_db = db_client.audible_books
|
|
87
|
+
items.each do |library_item|
|
|
88
|
+
isbn = library_item.isbn
|
|
89
|
+
if isbn
|
|
90
|
+
success = library_item.save_to_database(books_db)
|
|
91
|
+
total_saved += 1 if success
|
|
92
|
+
else
|
|
93
|
+
puts "#{Util::TAB}#{Util::WARNING_EMOJI} Skipping book with no ISBN: #{library_item}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
db_client.sync_times.touch(SYNC_TIME_KEY)
|
|
97
|
+
total_saved
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { returns T::Boolean }
|
|
101
|
+
def loaded_from_database?
|
|
102
|
+
@loaded_from_database
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sig { params(books_db: Database::AudibleBooks).returns(T::Boolean) }
|
|
106
|
+
def load_from_database(books_db)
|
|
107
|
+
puts "#{Util::INFO_EMOJI} Loading cached Audible library..."
|
|
108
|
+
|
|
109
|
+
rows = books_db.find_all
|
|
110
|
+
@items = rows.map { |row| LibraryItem.new(row) }
|
|
111
|
+
|
|
112
|
+
@loaded_from_database = true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { returns T::Boolean }
|
|
116
|
+
def any_finished_time_loaded?
|
|
117
|
+
items.any? { |library_item| !library_item.finished_at.nil? }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sig { params(finish_times_by_asin: T::Hash[String, DateTime]).void }
|
|
121
|
+
def populate_finish_times(finish_times_by_asin)
|
|
122
|
+
items.each do |library_item|
|
|
123
|
+
asin = library_item.asin
|
|
124
|
+
library_item.finished_at = if asin
|
|
125
|
+
finish_times_by_asin[asin]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@finished_items = @unfinished_items = nil # force recalculation
|
|
130
|
+
|
|
131
|
+
if total_finished < 1
|
|
132
|
+
puts "#{Util::INFO_EMOJI} No books in Audible library have been finished."
|
|
133
|
+
else
|
|
134
|
+
puts "#{Util::INFO_EMOJI} Loaded finished status for #{total_finished} " \
|
|
135
|
+
"#{finished_item_units} from Audible library."
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
sig { returns T::Array[LibraryItem] }
|
|
140
|
+
def finished_items
|
|
141
|
+
calculate_finished_unfinished_items
|
|
142
|
+
@finished_items
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig { returns T::Array[LibraryItem] }
|
|
146
|
+
def started_items
|
|
147
|
+
calculate_started_not_started_items
|
|
148
|
+
@started_items
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sig { returns T::Array[LibraryItem] }
|
|
152
|
+
def not_started_items
|
|
153
|
+
calculate_started_not_started_items
|
|
154
|
+
@not_started_items
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
sig { returns T::Array[LibraryItem] }
|
|
158
|
+
def unfinished_items
|
|
159
|
+
calculate_finished_unfinished_items
|
|
160
|
+
@unfinished_items
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
sig { returns Integer }
|
|
164
|
+
def total_finished
|
|
165
|
+
@total_finished ||= finished_items.size
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
sig { returns Integer }
|
|
169
|
+
def finished_percent
|
|
170
|
+
@finished_percent ||= (total_finished.to_f / total_items * 100).round
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
sig { returns Integer }
|
|
174
|
+
def total_started
|
|
175
|
+
@total_started ||= started_items.size
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig { returns Integer }
|
|
179
|
+
def started_percent
|
|
180
|
+
@started_percent ||= (total_started.to_f / total_items * 100).round
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
sig { returns Integer }
|
|
184
|
+
def total_unfinished
|
|
185
|
+
@total_unfinished ||= unfinished_items.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sig { returns Integer }
|
|
189
|
+
def unfinished_percent
|
|
190
|
+
@unfinished_percent ||= (total_unfinished.to_f / total_items * 100).round
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
sig { returns Integer }
|
|
194
|
+
def total_not_started
|
|
195
|
+
@total_not_started ||= not_started_items.size
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { returns Integer }
|
|
199
|
+
def not_started_percent
|
|
200
|
+
@not_started_percent ||= (total_not_started.to_f / total_items * 100).round
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
sig { returns String }
|
|
204
|
+
def finished_item_units
|
|
205
|
+
total_finished == 1 ? "book" : "books"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sig { returns String }
|
|
209
|
+
def item_units
|
|
210
|
+
total_items == 1 ? "book" : "books"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
sig { returns String }
|
|
214
|
+
def started_item_units
|
|
215
|
+
total_started == 1 ? "book" : "books"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sig { returns String }
|
|
219
|
+
def unfinished_item_units
|
|
220
|
+
total_unfinished == 1 ? "book" : "books"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
sig { returns String }
|
|
224
|
+
def not_started_item_units
|
|
225
|
+
total_not_started == 1 ? "book" : "books"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
sig { returns String }
|
|
229
|
+
def to_json
|
|
230
|
+
JSON.pretty_generate(items.map(&:to_h))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
234
|
+
def finished_items_summary(limit: 5, stylize: false)
|
|
235
|
+
lines = T.let([
|
|
236
|
+
"#{Util::DONE_EMOJI} #{total_finished} #{finished_item_units} " \
|
|
237
|
+
"(#{finished_percent}%) in Audible library have been finished:",
|
|
238
|
+
], T::Array[String])
|
|
239
|
+
lines.concat(finished_items.take(limit).map do |item|
|
|
240
|
+
item.to_s(indent_level: 1, stylize: stylize)
|
|
241
|
+
end)
|
|
242
|
+
lines << "#{Util::TAB}..." if total_finished > limit
|
|
243
|
+
lines << ""
|
|
244
|
+
lines.join("\n")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(T.nilable(String)) }
|
|
248
|
+
def not_started_items_summary(limit: 5, stylize: false)
|
|
249
|
+
return if total_not_started < 1
|
|
250
|
+
|
|
251
|
+
lines = T.let([
|
|
252
|
+
"🌱 #{total_not_started} #{not_started_item_units} (#{not_started_percent}%) " \
|
|
253
|
+
"in Audible library have not been started:",
|
|
254
|
+
], T::Array[String])
|
|
255
|
+
lines.concat(not_started_items.take(limit).map do |item|
|
|
256
|
+
item.to_s(indent_level: 1, stylize: stylize)
|
|
257
|
+
end)
|
|
258
|
+
lines << "#{Util::TAB}..." if total_not_started > limit
|
|
259
|
+
lines << ""
|
|
260
|
+
lines.join("\n")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(T.nilable(String)) }
|
|
264
|
+
def started_items_summary(limit: 5, stylize: false)
|
|
265
|
+
return if total_started < 1
|
|
266
|
+
|
|
267
|
+
lines = T.let([
|
|
268
|
+
"🔜 #{total_started} #{started_item_units} (#{started_percent}%) in Audible " \
|
|
269
|
+
"library are in progress:",
|
|
270
|
+
], T::Array[String])
|
|
271
|
+
lines.concat(started_items.take(limit).map do |item|
|
|
272
|
+
item.to_s(indent_level: 1, stylize: stylize)
|
|
273
|
+
end)
|
|
274
|
+
lines << "#{Util::TAB}..." if total_started > limit
|
|
275
|
+
lines << ""
|
|
276
|
+
lines.join("\n")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
280
|
+
def to_s(limit: 5, stylize: false)
|
|
281
|
+
[
|
|
282
|
+
"📚 Loaded #{total_items} #{item_units} from Audible library",
|
|
283
|
+
not_started_items_summary(limit: limit, stylize: stylize),
|
|
284
|
+
finished_items_summary(limit: limit, stylize: stylize),
|
|
285
|
+
started_items_summary(limit: limit, stylize: stylize),
|
|
286
|
+
].compact.join("\n")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
sig { void }
|
|
292
|
+
def calculate_finished_unfinished_items
|
|
293
|
+
return if @finished_items && @unfinished_items
|
|
294
|
+
@finished_items, @unfinished_items = items.partition(&:finished?)
|
|
295
|
+
@finished_items.sort! do |a, b|
|
|
296
|
+
a_finish_time = a.finished_at
|
|
297
|
+
b_finish_time = b.finished_at
|
|
298
|
+
if a_finish_time && b_finish_time
|
|
299
|
+
T.must(b_finish_time <=> a_finish_time)
|
|
300
|
+
elsif a_finish_time
|
|
301
|
+
-1
|
|
302
|
+
elsif b_finish_time
|
|
303
|
+
1
|
|
304
|
+
else
|
|
305
|
+
0
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
sig { void }
|
|
311
|
+
def calculate_started_not_started_items
|
|
312
|
+
return if @started_items && @not_started_items
|
|
313
|
+
@started_items, @not_started_items = unfinished_items.partition(&:started?)
|
|
314
|
+
@started_items.sort! { |a, b| b.percent_complete <=> a.percent_complete }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|