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
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,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
|