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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +113 -0
  4. data/bin/good-audible-story-sync +6 -0
  5. data/lib/good_audible_story_sync/audible/auth.rb +262 -0
  6. data/lib/good_audible_story_sync/audible/auth_flow.rb +70 -0
  7. data/lib/good_audible_story_sync/audible/client.rb +220 -0
  8. data/lib/good_audible_story_sync/audible/library.rb +318 -0
  9. data/lib/good_audible_story_sync/audible/library_item.rb +213 -0
  10. data/lib/good_audible_story_sync/audible/user_profile.rb +39 -0
  11. data/lib/good_audible_story_sync/audible.rb +13 -0
  12. data/lib/good_audible_story_sync/database/audible_books.rb +66 -0
  13. data/lib/good_audible_story_sync/database/client.rb +70 -0
  14. data/lib/good_audible_story_sync/database/credentials.rb +48 -0
  15. data/lib/good_audible_story_sync/database/goodreads_books.rb +60 -0
  16. data/lib/good_audible_story_sync/database/storygraph_books.rb +74 -0
  17. data/lib/good_audible_story_sync/database/sync_times.rb +52 -0
  18. data/lib/good_audible_story_sync/database.rb +16 -0
  19. data/lib/good_audible_story_sync/goodreads/auth.rb +137 -0
  20. data/lib/good_audible_story_sync/goodreads/auth_flow.rb +70 -0
  21. data/lib/good_audible_story_sync/goodreads/book.rb +171 -0
  22. data/lib/good_audible_story_sync/goodreads/client.rb +98 -0
  23. data/lib/good_audible_story_sync/goodreads/library.rb +149 -0
  24. data/lib/good_audible_story_sync/goodreads.rb +12 -0
  25. data/lib/good_audible_story_sync/input_loop.rb +214 -0
  26. data/lib/good_audible_story_sync/options.rb +70 -0
  27. data/lib/good_audible_story_sync/storygraph/auth.rb +91 -0
  28. data/lib/good_audible_story_sync/storygraph/auth_flow.rb +70 -0
  29. data/lib/good_audible_story_sync/storygraph/book.rb +261 -0
  30. data/lib/good_audible_story_sync/storygraph/client.rb +247 -0
  31. data/lib/good_audible_story_sync/storygraph/library.rb +183 -0
  32. data/lib/good_audible_story_sync/storygraph/look_up_book_flow.rb +172 -0
  33. data/lib/good_audible_story_sync/storygraph/mark_finished_flow.rb +201 -0
  34. data/lib/good_audible_story_sync/storygraph.rb +14 -0
  35. data/lib/good_audible_story_sync/util/cipher.rb +43 -0
  36. data/lib/good_audible_story_sync/util/keychain.rb +32 -0
  37. data/lib/good_audible_story_sync/util.rb +92 -0
  38. data/lib/good_audible_story_sync/version.rb +6 -0
  39. data/lib/good_audible_story_sync.rb +14 -0
  40. 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