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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25bf4c07587019619d34e8daab5729bc3e6e62863475dc827ad5cc265ccce7b6
4
+ data.tar.gz: b95565652c0913a34c624aef5d00d0ba6351e59e5c895fc9faf0853bc85c9ea7
5
+ SHA512:
6
+ metadata.gz: 23aff0e8f660a9661c771a8f031e23a3e2d95b70790ccbfdd08de55e7c799e882345c996b3d8242a80e2c31a99181cdf7aa122b11bbd870e45625aed4f277ab2
7
+ data.tar.gz: b1293d14b8bbde5a0d4ec4dc6a7d1059f637b052b1bbbf9bc0502405026fddd10c584740c64bf93d9155e19d02cd77e19b60ee5d1beed597a488d26f7a12dff1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Sarah Vessels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # good-audible-story-sync
2
+
3
+ Script to sync your read books from Audible to Goodreads and StoryGraph.
4
+
5
+ ## How to use
6
+
7
+ > [!NOTE]
8
+ > This is intended to be run from macOS.
9
+
10
+ Download the [latest release](https://github.com/cheshire137/good-audible-story-sync/releases/latest) of the gem. Install it via:
11
+
12
+ ```sh
13
+ gem install good-audible-story-sync.gem
14
+ ```
15
+
16
+ There should now be a `good-audible-story-sync` executable in your path. Run it via:
17
+
18
+ ```sh
19
+ good-audible-story-sync
20
+ ```
21
+
22
+ You will be prompted to log in to Audible and Storygraph. The tool saves your encrypted login
23
+ credentials in a SQLite database and stores the encryption key in the macOS keychain, under the name
24
+ 'good_audible_story_sync_encryption_key'.
25
+
26
+ After signing into Audible, it will create a new device that you can see on your Amazon
27
+ [Installed on Devices](https://www.amazon.com/hz/mycd/digital-console/devicedetails?deviceFamily=AUDIBLE_APP)
28
+ page for Audible. This allows accessing your Audible library information, such as which books
29
+ you finished reading and when.
30
+
31
+ ### Options
32
+
33
+ ```sh
34
+ Usage: good-audible-story-sync [options]
35
+ -d DATABASE_FILE, Path to Sqlite database file. Defaults to good_audible_story_sync.db.
36
+ --database-file
37
+ -e EXPIRATION_DAYS, Max number of days to use cached data, such as Audible library, before refreshing. Defaults to 1.
38
+ --expiration-days
39
+ ```
40
+
41
+ ### Sample output
42
+
43
+ ```sh
44
+ % good-audible-story-sync
45
+ 🔐 Looking for 'good_audible_story_sync_encryption_key' in cheshire137's keychain...
46
+ ℹ️ Using GoodAudibleStorySync encryption key from keychain
47
+ ⚙️ Parsing options...
48
+ ℹ️ Ensuring table audible_books exists...
49
+ ℹ️ Ensuring table storygraph_books exists...
50
+ ℹ️ Ensuring table goodreads_books exists...
51
+ ℹ️ Ensuring table credentials exists...
52
+ ℹ️ Ensuring table sync_times exists...
53
+ a) display Audible library
54
+ p) display Audible user profile
55
+ g) display Goodreads library
56
+ s) display Storygraph library
57
+ l) look up book on Storygraph
58
+ c) update Storygraph library cache
59
+ f) mark finished books on Storygraph
60
+ q) quit
61
+ Choose an option:
62
+ ```
63
+
64
+ ## How to develop
65
+
66
+ Built using Ruby version 3.3.6 on macOS.
67
+
68
+ ```sh
69
+ bundle install
70
+ bin/good-audible-story-sync
71
+ ```
72
+
73
+ Run `srb tc` to run the [Sorbet type checker](https://sorbet.org/).
74
+
75
+ You can also run `irb` to get an interactive Ruby console with some convenient methods available for debugging, including:
76
+
77
+ - #load_db_client
78
+ - #load_options
79
+ - #get_audible_auth
80
+ - #get_audible_client
81
+ - #get_audible_library
82
+ - #get_storygraph_auth
83
+ - #get_storygraph_client
84
+ - #get_goodreads_auth
85
+ - #get_goodreads_client
86
+
87
+ ### Creating a tag
88
+
89
+ Update `VERSION` in [version.rb](./lib/good_audible_story_sync/version.rb).
90
+
91
+ ```sh
92
+ git tag v0.0.x main # use the same version string as in `VERSION`
93
+ git push origin tag v0.0.x
94
+ ```
95
+
96
+ This will trigger a workflow that builds the gem and creates a new release.
97
+
98
+ ### Building the gem
99
+
100
+ ```sh
101
+ gem build good_audible_story_sync.gemspec
102
+ ```
103
+
104
+ This will create a file like project_pull_mover-0.0.1.gem which you can then install:
105
+
106
+ ```sh
107
+ gem install good_audible_story_sync-0.0.1.gem
108
+ ```
109
+
110
+ ## Thanks
111
+
112
+ - [mkb79's Python Audible library](https://github.com/mkb79/Audible) for providing an example of how to authenticate with Audible and use its API, including the [unofficial API docs](https://audible.readthedocs.io/en/master/misc/external_api.html).
113
+ - [mechanize gem](https://github.com/sparklemotion/mechanize) for letting me automate Storygraph and Goodreads even without an API.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ require_relative "../lib/good_audible_story_sync"
5
+
6
+ GoodAudibleStorySync::InputLoop.run(script_name: __FILE__)
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "base64"
5
+ require "digest"
6
+ require "httparty"
7
+ require "json"
8
+ require "securerandom"
9
+ require "uri"
10
+
11
+ module GoodAudibleStorySync
12
+ module Audible
13
+ class Auth
14
+ extend T::Sig
15
+
16
+ US_MARKETPLACE_ID = "AF2M0KC94RCEA"
17
+ US_DOMAIN = "com"
18
+ CREDENTIALS_DB_KEY = "audible"
19
+
20
+ class InvalidTokenError < StandardError; end
21
+ class ForbiddenError < StandardError; end
22
+
23
+ sig { params(action: String, response: HTTParty::Response).void }
24
+ def self.handle_http_error(action:, response:)
25
+ code = response.code
26
+ raise ForbiddenError if code == 403
27
+
28
+ json = begin
29
+ JSON.parse(response.body)
30
+ rescue JSON::ParserError
31
+ raise "Failed to #{action} (#{code}):\n#{response.body}"
32
+ end
33
+
34
+ error_type = json["error"]
35
+ raise InvalidTokenError if error_type == "invalid_token"
36
+
37
+ if error_type
38
+ error_description = json["error_description"]
39
+ raise "Failed to #{action} (#{code}): #{error_type} #{error_description}"
40
+ end
41
+
42
+ message = json["message"]
43
+ if message
44
+ raise "Failed to #{action} (#{code}): #{message}"
45
+ end
46
+
47
+ raise "Failed to #{action} (#{code}):\n#{response.body}"
48
+ end
49
+
50
+ sig { params(expires_s: Integer).returns(Time) }
51
+ def self.expiration_time_from_seconds(expires_s)
52
+ Time.now.utc + (expires_s/86400.0)
53
+ end
54
+
55
+ sig { params(source_token: String).returns([String, Time]) }
56
+ def self.refresh_token(source_token)
57
+ raise "No source token provided to refresh" if source_token.size < 1
58
+
59
+ body = {
60
+ "requested_token_type" => "access_token",
61
+ "source_token_type" => "refresh_token",
62
+ "app_name" => "Audible",
63
+ "app_version" => "3.56.2",
64
+ "source_token" => source_token,
65
+ }
66
+ url = "https://api.amazon.#{US_DOMAIN}/auth/token"
67
+ puts "POST #{url}"
68
+ response = HTTParty.post(url, body: body)
69
+ handle_http_error(action: "refresh token", response: response) unless response.code == 200
70
+
71
+ json = JSON.parse(response.body)
72
+ expires_s = json["expires_in"].to_i
73
+ expires = expiration_time_from_seconds(expires_s)
74
+ access_token = json["access_token"]
75
+ [access_token, expires]
76
+ end
77
+
78
+ attr_reader :adp_token, :device_private_key, :refresh_token, :website_cookies,
79
+ :store_authentication_cookie, :device_info, :customer_info
80
+
81
+ sig { returns T.nilable(String) }
82
+ attr_accessor :access_token
83
+
84
+ sig { returns T.nilable(Time) }
85
+ attr_accessor :expires
86
+
87
+ sig { void }
88
+ def initialize
89
+ verifier = SecureRandom.random_bytes(32)
90
+ @code_verifier = Base64.urlsafe_encode64(verifier).delete_suffix("=")
91
+ @device_serial = SecureRandom.uuid.upcase.delete('-')
92
+ @client_id = "#{device_serial}#A2CZJZGLK2JJVM".unpack1('H*')
93
+ @auth_code = nil
94
+ @adp_token = nil
95
+ @device_private_key = nil
96
+ @access_token = nil
97
+ @refresh_token = nil
98
+ @expires = nil
99
+ @website_cookies = nil
100
+ @store_authentication_cookie = nil
101
+ @device_info = nil
102
+ @customer_info = nil
103
+ @loaded_from_database = T.let(false, T::Boolean)
104
+ end
105
+
106
+ sig { returns String }
107
+ def oauth_url
108
+ m = Digest::SHA256.digest(code_verifier)
109
+ s256_code_challenge = Base64.urlsafe_encode64(m).delete_suffix("=")
110
+ base_url = "https://www.amazon.#{US_DOMAIN}/ap/signin"
111
+ return_to = "https://www.amazon.#{US_DOMAIN}/ap/maplanding"
112
+ assoc_handle = "amzn_audible_ios_us"
113
+ page_id = "amzn_audible_ios"
114
+ params = {
115
+ "openid.oa2.response_type" => "code",
116
+ "openid.oa2.code_challenge_method" => "S256",
117
+ "openid.oa2.code_challenge" => s256_code_challenge,
118
+ "openid.return_to" => return_to,
119
+ "openid.assoc_handle" => assoc_handle,
120
+ "openid.identity" => "http://specs.openid.net/auth/2.0/identifier_select",
121
+ "pageId" => page_id,
122
+ "accountStatusPolicy" => "P1",
123
+ "openid.claimed_id" => "http://specs.openid.net/auth/2.0/identifier_select",
124
+ "openid.mode" => "checkid_setup",
125
+ "openid.ns.oa2" => "http://www.amazon.com/ap/ext/oauth/2",
126
+ "openid.oa2.client_id" => "device:#{client_id}",
127
+ "openid.ns.pape" => "http://specs.openid.net/extensions/pape/1.0",
128
+ "marketPlaceId" => US_MARKETPLACE_ID,
129
+ "openid.oa2.scope" => "device_auth_access",
130
+ "forceMobileLayout" => "true",
131
+ "openid.ns" => "http://specs.openid.net/auth/2.0",
132
+ "openid.pape.max_auth_age" => "0",
133
+ }
134
+ "#{base_url}?#{URI.encode_www_form(params)}"
135
+ end
136
+
137
+ sig { params(url: String).void }
138
+ def set_authorization_code_from_oauth_redirect_url(url)
139
+ uri = URI.parse(url)
140
+ query = uri.query
141
+ if query
142
+ redirect_params = URI.decode_www_form(query).to_h
143
+ @auth_code = redirect_params["openid.oa2.authorization_code"]
144
+ end
145
+ end
146
+
147
+ sig { returns T::Boolean }
148
+ def register_device
149
+ raise "No authorization code has been set" if auth_code.nil? || auth_code.size < 1
150
+
151
+ body = {
152
+ "requested_token_type" => ["bearer", "mac_dms", "website_cookies",
153
+ "store_authentication_cookie"],
154
+ "cookies" => {"website_cookies" => [], "domain" => ".amazon.#{US_DOMAIN}"},
155
+ "registration_data" => {
156
+ "domain" => "Device",
157
+ "app_version" => "3.56.2",
158
+ "device_serial" => device_serial,
159
+ "device_type" => "A2CZJZGLK2JJVM",
160
+ "device_name" => "GoodAudibleStorySync",
161
+ "os_version" => "15.0.0",
162
+ "software_version" => "35602678",
163
+ "device_model" => "iPhone",
164
+ "app_name" => "Audible",
165
+ },
166
+ "auth_data" => {
167
+ "client_id" => client_id,
168
+ "authorization_code" => auth_code,
169
+ "code_verifier" => code_verifier,
170
+ "code_algorithm" => "SHA-256",
171
+ "client_domain" => "DeviceLegacy",
172
+ },
173
+ "requested_extensions" => ["device_info", "customer_info"],
174
+ }
175
+
176
+ url = "https://api.amazon.#{US_DOMAIN}/auth/register"
177
+ puts "POST #{url}"
178
+ response = HTTParty.post(url, body: body.to_json)
179
+ unless response.code == 200
180
+ self.class.handle_http_error(action: "register device", response: response)
181
+ end
182
+
183
+ resp_json = JSON.parse(response.body)
184
+ success_data = resp_json.dig("response", "success")
185
+ raise "Failed to register device:\n#{response.body}" unless success_data
186
+
187
+ tokens = success_data["tokens"]
188
+ @adp_token = tokens.dig("mac_dms", "adp_token")
189
+ @device_private_key = tokens.dig("mac_dms", "device_private_key")
190
+ @store_authentication_cookie = tokens["store_authentication_cookie"]
191
+ @access_token = tokens.dig("bearer", "access_token")
192
+ @refresh_token = tokens.dig("bearer", "refresh_token")
193
+ expires_s = tokens.dig("bearer", "expires_in").to_i
194
+ @expires = self.class.expiration_time_from_seconds(expires_s)
195
+
196
+ extensions = success_data["extensions"]
197
+ @device_info = extensions["device_info"]
198
+ @customer_info = extensions["customer_info"]
199
+
200
+ @website_cookies = {}
201
+ tokens["website_cookies"].each do |cookie|
202
+ value = cookie["Value"]
203
+ @website_cookies[cookie["Name"]] = if value
204
+ value.gsub('"', '')
205
+ end
206
+ end
207
+
208
+ !@access_token.nil? && !@access_token.strip.empty?
209
+ end
210
+
211
+ sig { returns T::Hash[String, T.untyped] }
212
+ def to_h
213
+ {
214
+ "adp_token" => adp_token,
215
+ "device_private_key" => device_private_key,
216
+ "access_token" => access_token,
217
+ "refresh_token" => refresh_token,
218
+ "expires" => expires,
219
+ "website_cookies" => website_cookies,
220
+ "store_authentication_cookie" => store_authentication_cookie,
221
+ "device_info" => device_info,
222
+ "customer_info" => customer_info,
223
+ }
224
+ end
225
+
226
+ sig { params(cred_client: Database::Credentials).void }
227
+ def save_to_database(cred_client)
228
+ cred_client.upsert(key: CREDENTIALS_DB_KEY, value: to_h)
229
+ end
230
+
231
+ sig { returns T::Boolean }
232
+ def loaded_from_database?
233
+ @loaded_from_database
234
+ end
235
+
236
+ sig { params(creds_db: Database::Credentials).returns(T::Boolean) }
237
+ def load_from_database(creds_db)
238
+ audible_data = creds_db.find(key: CREDENTIALS_DB_KEY)
239
+ unless audible_data
240
+ puts "#{Util::INFO_EMOJI} No Audible credentials found in database"
241
+ return false
242
+ end
243
+
244
+ @adp_token = audible_data["adp_token"]
245
+ @device_private_key = audible_data["device_private_key"]
246
+ @access_token = audible_data["access_token"]
247
+ @refresh_token = audible_data["refresh_token"]
248
+ @expires = audible_data["expires"]
249
+ @website_cookies = audible_data["website_cookies"]
250
+ @store_authentication_cookie = audible_data["store_authentication_cookie"]
251
+ @device_info = audible_data["device_info"]
252
+ @customer_info = audible_data["customer_info"]
253
+
254
+ @loaded_from_database = !!(!@access_token.nil? && !@access_token.strip.empty?)
255
+ end
256
+
257
+ private
258
+
259
+ attr_reader :code_verifier, :device_serial, :client_id, :auth_code
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require "uri"
5
+
6
+ module GoodAudibleStorySync
7
+ module Audible
8
+ class AuthFlow
9
+ extend T::Sig
10
+
11
+ sig { params(db_client: Database::Client).returns(T.nilable(Auth)) }
12
+ def self.run(db_client:)
13
+ new(db_client: db_client).run
14
+ end
15
+
16
+ sig { params(db_client: Database::Client).void }
17
+ def initialize(db_client:)
18
+ @audible_auth = Auth.new
19
+ @credentials_db = db_client.credentials
20
+ end
21
+
22
+ sig { returns T.nilable(Auth) }
23
+ def run
24
+ success = load_from_database || log_in_via_oauth
25
+ success ? audible_auth : nil
26
+ end
27
+
28
+ private
29
+
30
+ sig { returns Auth }
31
+ attr_reader :audible_auth
32
+
33
+ sig { returns T::Boolean }
34
+ def load_from_database
35
+ puts "#{Util::INFO_EMOJI} Looking for saved Audible credentials in database..."
36
+ success = audible_auth.load_from_database(@credentials_db)
37
+ puts "#{Util::SUCCESS_EMOJI} Found saved Audible credentials." if success
38
+ success
39
+ end
40
+
41
+ sig { returns T::Boolean }
42
+ def log_in_via_oauth
43
+ puts "Please authenticate with Audible via: #{audible_auth.oauth_url}"
44
+ puts "\nEnter the URL you were redirected to after logging in:"
45
+ url_after_login = gets.chomp
46
+ audible_auth.set_authorization_code_from_oauth_redirect_url(url_after_login)
47
+
48
+ puts "\n#{Util::INFO_EMOJI} Registering Audible device..."
49
+ success = begin
50
+ audible_auth.register_device
51
+ rescue => err
52
+ puts "#{Util::ERROR_EMOJI} Error registering device: #{err}"
53
+ return false
54
+ end
55
+
56
+ unless success
57
+ puts "\n#{Util::ERROR_EMOJI} Failed to authenticate with Audible"
58
+ return false
59
+ end
60
+
61
+ device_name = audible_auth.device_info["device_name"]
62
+ puts "\n#{Util::SUCCESS_EMOJI} Successfully authenticated with Audible and " \
63
+ "registered device: #{device_name}"
64
+
65
+ audible_auth.save_to_database(@credentials_db)
66
+ true
67
+ end
68
+ end
69
+ end
70
+ end