Explorar o código

[added] First version.

TnS-hun %!s(int64=8) %!d(string=hai) anos
achega
d29b42d92c

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+/.idea/

+ 180 - 0
kobo-book-downloader/Commands.py

@@ -0,0 +1,180 @@
+from Globals import Globals
+from Kobo import Kobo, KoboException
+
+import colorama
+
+import os
+
+class Commands:
+	# It wasn't possible to format the main help message to my liking, so using a custom one.
+	# This was the most annoying:
+	#
+	# commands:
+	#   command <-- absolutely unneeded text
+	#     get     List unread books
+	#     list    Get book
+	#
+	# See https://stackoverflow.com/questions/13423540/ and https://stackoverflow.com/questions/11070268/
+	@staticmethod
+	def ShowUsage():
+		usage = \
+"""Kobo book downloader and DRM remover
+
+Usage:
+  kobo-book-downloader [--help] command ...
+
+Commands:
+  get      Download book
+  info     Show the location of the configuration file
+  list     List your books
+
+Optional arguments:
+  -h, --help    Show this help message and exit
+
+Examples:
+  kobo-book-downloader get /dir/book.epub 01234567-89ab-cdef-0123-456789abcdef   Download book
+  kobo-book-downloader get /dir/ 01234567-89ab-cdef-0123-456789abcdef            Download book and name the file automatically
+  kobo-book-downloader get /dir/ --all                                           Download all your books
+  kobo-book-downloader info                                                      Show the location of the program's configuration file
+  kobo-book-downloader list                                                      List your unread books
+  kobo-book-downloader list --all                                                List all your books
+  kobo-book-downloader list --help                                               Get additional help for the list command (it works for get too)"""
+
+		print( usage )
+
+	@staticmethod
+	def __GetBookAuthor( book: dict ) -> str:
+		contributors = book.get( "ContributorRoles" )
+
+		authors = []
+		for contributor in contributors:
+			role = contributor.get( "Role" )
+			if role == "Author":
+				authors.append( contributor[ "Name" ] )
+
+		# Unfortunately the role field is not filled out in the data returned by the "library_sync" endpoint, so we only
+		# use the first author and hope for the best. Otherwise we would get non-main authors too. For example Christopher
+		# Buckley beside Joseph Heller for the -- terrible -- novel Catch-22.
+		if len( authors ) == 0 and len( contributors ) > 0:
+			authors.append( contributors[ 0 ][ "Name" ] )
+
+		return " & ".join( authors )
+
+	@staticmethod
+	def __SanitizeFileName( fileName: str ) -> str:
+		result = ""
+		for c in fileName:
+			if c.isalnum() or " ,;.!(){}[]#$'-+@_".find( c ) >= 0:
+				result += c
+
+		result = result.strip( " ." )
+		result = result[ :100 ] # Limit the length -- mostly because of Windows. It would be better to do it on the full path using MAX_PATH.
+		return result
+
+	@staticmethod
+	def __MakeFileNameForBook( book: dict ) -> str:
+		fileName = ""
+
+		author = Commands.__GetBookAuthor( book )
+		if len( author ) > 0:
+			fileName = author + " - "
+
+		fileName += book[ "Title" ]
+		fileName = Commands.__SanitizeFileName( fileName )
+		fileName += ".epub"
+
+		return fileName
+
+	@staticmethod
+	def __GetBook( revisionId: str, outputPath: str ) -> None:
+		if os.path.isdir( outputPath ):
+			book = Globals.Kobo.GetBookInfo( revisionId )
+			fileName = Commands.__MakeFileNameForBook( book )
+			outputPath = os.path.join( outputPath, fileName )
+		else:
+			parentPath = os.path.dirname( outputPath )
+			if not os.path.isdir( parentPath ):
+				raise KoboException( "The parent directory ('%s') of the output file must exist." % parentPath )
+
+		print( "Downloading book to '%s'." % outputPath )
+		Globals.Kobo.Download( revisionId, Kobo.DisplayProfile, outputPath )
+
+	@staticmethod
+	def __GetAllBooks( outputPath: str ) -> None:
+		if not os.path.isdir( outputPath ):
+			raise KoboException( "The output path must be a directory when downloading all books." )
+
+		bookList = Globals.Kobo.GetMyBookList()
+
+		for entitlement in bookList:
+			newEntitlement = entitlement.get( "NewEntitlement" )
+			if newEntitlement is None:
+				continue
+
+			bookMetadata = newEntitlement[ "BookMetadata" ]
+			fileName = Commands.__MakeFileNameForBook( bookMetadata )
+			outputFilePath = os.path.join( outputPath, fileName )
+
+			print( "Downloading book to '%s'." % outputFilePath )
+			Globals.Kobo.Download( bookMetadata[ "RevisionId" ], Kobo.DisplayProfile, outputFilePath )
+
+	@staticmethod
+	def GetBookOrBooks( revisionId: str, outputPath: str, getAll: bool ) -> None:
+		revisionIdIsSet = ( revisionId is not None ) and len( revisionId ) > 0
+
+		if getAll:
+			if revisionIdIsSet:
+				raise KoboException( "Got unexpected book identifier parameter ('%s')." % revisionId )
+
+			Commands.__GetAllBooks( outputPath )
+		else:
+			if not revisionIdIsSet:
+				raise KoboException( "Missing book identifier parameter. Did you mean to use the --all parameter?" )
+
+			Commands.__GetBook( revisionId, outputPath )
+
+	@staticmethod
+	def __IsBookRead( newEntitlement: dict ) -> bool:
+		readingState = newEntitlement.get( "ReadingState" )
+		if readingState is None:
+			return False
+
+		statusInfo = readingState.get( "StatusInfo" )
+		if statusInfo is None:
+			return False
+
+		status = statusInfo.get( "Status" )
+		return status == "Finished"
+
+	@staticmethod
+	def ListBooks( listAll: bool ) -> None:
+		colorama.init()
+
+		bookList = Globals.Kobo.GetMyBookList()
+		rows = []
+
+		for entitlement in bookList:
+			newEntitlement = entitlement.get( "NewEntitlement" )
+			if newEntitlement is None:
+				continue
+
+			if ( not listAll ) and Commands.__IsBookRead( newEntitlement ):
+				continue
+
+			bookMetadata = newEntitlement[ "BookMetadata" ]
+			rows.append( [ bookMetadata[ "RevisionId" ], bookMetadata[ "Title" ], Commands.__GetBookAuthor( bookMetadata ) ] )
+
+		rows = sorted( rows, key = lambda columns: columns[ 1 ] )
+		for columns in rows:
+			revisionId = colorama.Style.DIM + columns[ 0 ] + colorama.Style.RESET_ALL
+			title = colorama.Style.BRIGHT + columns[ 1 ] + colorama.Style.RESET_ALL
+			author = columns[ 2 ]
+
+			if len( author ) > 0:
+				print( "%s \t %s by %s" % ( revisionId, title, author ) )
+			else:
+				print( "%s \t %s" % ( revisionId, title ) )
+
+	@staticmethod
+	def Info():
+		print( "The configuration file is located at:\n%s" % Globals.Settings.SettingsFilePath )

+ 3 - 0
kobo-book-downloader/Globals.py

@@ -0,0 +1,3 @@
+class Globals:
+	Kobo = None
+	Settings = None

+ 279 - 0
kobo-book-downloader/Kobo.py

@@ -0,0 +1,279 @@
+from Globals import Globals
+from KoboDrmRemover import KoboDrmRemover
+
+import requests
+
+from typing import Dict, Tuple
+import base64
+import html
+import os
+import re
+import urllib
+import uuid
+
+class KoboException( Exception ):
+	pass
+
+# The hook's workflow is based on this:
+# https://github.com/requests/toolbelt/blob/master/requests_toolbelt/auth/http_proxy_digest.py
+def ReauthenticationHook( r, *args, **kwargs ):
+	if r.status_code != requests.codes.unauthorized: # 401
+		return
+
+	print( "Refreshing expired authentication token" )
+
+	# Consume content and release the original connection to allow our new request to reuse the same one.
+	r.content
+	r.close()
+
+	prep = r.request.copy()
+
+	# Refresh the authentication token and use it.
+	Globals.Kobo.RefreshAuthentication()
+	headers = Kobo.GetHeaderWithAccessToken()
+	prep.headers[ "Authorization" ] = headers[ "Authorization" ]
+
+	# Don't retry to reauthenticate this request again.
+	prep.deregister_hook( "response", ReauthenticationHook )
+
+	# Resend the failed request.
+	_r = r.connection.send( prep, **kwargs )
+	_r.history.append( r )
+	_r.request = prep
+
+	return _r
+
+class Kobo:
+	Affiliate = "Kobo"
+	ApplicationVersion = "7.1.21543"
+	DefaultPlatformId = "00000000-0000-0000-0000-000000004000"
+	DisplayProfile = "Android"
+
+	def __init__( self ):
+		self.InitializationSettings = {}
+		self.Session = requests.session()
+
+	# This could be added to the session but then we would need to add { "Authorization": None } headers to all other
+	# functions that doesn't need authorization.
+	@staticmethod
+	def GetHeaderWithAccessToken() -> dict:
+		authorization = "Bearer " + Globals.Settings.AccessToken
+		headers = { "Authorization": authorization }
+		return headers
+
+	# This could be added to the session too. See the comment at GetHeaderWithAccessToken.
+	@staticmethod
+	def __GetReauthenticationHook() -> dict:
+		return { "response": ReauthenticationHook }
+
+	# The initial device authentication request for a non-logged in user doesn't require a user key, and the returned
+	# user key can't be used for anything.
+	def AuthenticateDevice( self, userKey: str = "" ) -> None:
+		if len( Globals.Settings.DeviceId ) == 0:
+			Globals.Settings.DeviceId = str( uuid.uuid4() )
+			Globals.Settings.AccessToken = ""
+			Globals.Settings.RefreshToken = ""
+
+		postData = {
+			"AffiliateName": Kobo.Affiliate,
+			"AppVersion": Kobo.ApplicationVersion,
+			"ClientKey": base64.b64encode( Kobo.DefaultPlatformId.encode() ).decode(),
+			"DeviceId": Globals.Settings.DeviceId,
+			"PlatformId": Kobo.DefaultPlatformId
+		}
+
+		if len( userKey ) > 0:
+			postData[ "UserKey" ] = userKey
+
+		response = self.Session.post( "https://storeapi.kobo.com/v1/auth/device", json = postData )
+		response.raise_for_status()
+		jsonResponse = response.json()
+
+		if jsonResponse[ "TokenType" ] != "Bearer":
+			raise KoboException( "Device authentication returned with an unsupported token type: '%s'" % jsonResponse[ "TokenType" ] )
+
+		Globals.Settings.AccessToken = jsonResponse[ "AccessToken" ]
+		Globals.Settings.RefreshToken = jsonResponse[ "RefreshToken" ]
+		if not Globals.Settings.AreAuthenticationSettingsSet():
+			raise KoboException( "Authentication settings are not set after device authentication." )
+
+		if len( userKey ) > 0:
+			Globals.Settings.UserKey = jsonResponse[ "UserKey" ]
+
+		Globals.Settings.Save()
+
+	def RefreshAuthentication( self ) -> None:
+		headers = Kobo.GetHeaderWithAccessToken()
+
+		postData = {
+			"AppVersion": Kobo.ApplicationVersion,
+			"ClientKey": base64.b64encode( Kobo.DefaultPlatformId.encode() ).decode(),
+			"PlatformId": Kobo.DefaultPlatformId,
+			"RefreshToken": Globals.Settings.RefreshToken
+		}
+
+		# The reauthentication hook is intentionally not set.
+		response = self.Session.post( "https://storeapi.kobo.com/v1/auth/refresh", json = postData, headers = headers )
+		response.raise_for_status()
+		jsonResponse = response.json()
+
+		if jsonResponse[ "TokenType" ] != "Bearer":
+			raise KoboException( "Authentication refresh returned with an unsupported token type: '%s'" % jsonResponse[ "TokenType" ] )
+
+		Globals.Settings.AccessToken = jsonResponse[ "AccessToken" ]
+		Globals.Settings.RefreshToken = jsonResponse[ "RefreshToken" ]
+		if not Globals.Settings.AreAuthenticationSettingsSet():
+			raise KoboException( "Authentication settings are not set after authentication refresh." )
+
+		Globals.Settings.Save()
+
+	def LoadInitializationSettings( self ) -> None:
+		headers = Kobo.GetHeaderWithAccessToken()
+		hooks = Kobo.__GetReauthenticationHook()
+		response = self.Session.get( "https://storeapi.kobo.com/v1/initialization", headers = headers, hooks = hooks )
+		response.raise_for_status()
+		jsonResponse = response.json()
+		self.InitializationSettings = jsonResponse[ "Resources" ]
+
+	def __GetAuthenticationUrlFromLoginPage( self ) -> str:
+		signInPageUrl = self.InitializationSettings[ "sign_in_page" ]
+
+		params = {
+			"wsa": Kobo.Affiliate,
+			"wscf": "kepub",
+			"pwsav": Kobo.ApplicationVersion,
+			"pwspid": Kobo.DefaultPlatformId,
+			"pwsdid": Globals.Settings.DeviceId
+		}
+
+		response = self.Session.get( signInPageUrl, params = params )
+		response.raise_for_status()
+		htmlResponse = response.text
+
+		match = re.search( r"""<form action="(/[^"]+/Authenticate\?[^"]+)" method="post">""", htmlResponse )
+		if match is None:
+			raise KoboException( "Can't find login form. The page format might have changed." )
+
+		authenticationUrl = match.group( 1 )
+		authenticationUrl = html.unescape( authenticationUrl )
+		authenticationUrl = urllib.parse.urljoin( signInPageUrl, authenticationUrl )
+		return authenticationUrl
+
+	def Login( self, email: str, password: str ) -> None:
+		authenticationUrl = self.__GetAuthenticationUrlFromLoginPage()
+
+		postData = {
+			"EditModel.Email": email,
+			"EditModel.Password": password,
+			"EditModel.AuthenticationAction": "Authenticate",
+			"AffiliateObject.Name": Kobo.Affiliate,
+			"IsFTE": "False",
+			"RequireSharedToken": "False"
+		}
+
+		response = self.Session.post( authenticationUrl, data = postData )
+		response.raise_for_status()
+		htmlResponse = response.text
+
+		match = re.search( r"'(kobo://UserAuthenticated\?[^']+)';", htmlResponse )
+		if match is None:
+			raise KoboException( "Authenticated user URL can't be found. The page format might have changed." )
+
+		url = match.group( 1 )
+		parsed = urllib.parse.urlparse( url )
+		parsedQueries = urllib.parse.parse_qs( parsed.query )
+		Globals.Settings.UserId = parsedQueries[ "userId" ][ 0 ] # We don't call Settings.Save here, AuthenticateDevice will do that if it succeeds.
+		userKey = parsedQueries[ "userKey" ][ 0 ]
+
+		self.AuthenticateDevice( userKey )
+
+	def GetBookInfo( self, productId: str ) -> dict:
+		url = self.InitializationSettings[ "book" ].replace( "{ProductId}", productId )
+		headers = Kobo.GetHeaderWithAccessToken()
+		hooks = Kobo.__GetReauthenticationHook()
+
+		response = self.Session.get( url, headers = headers, hooks = hooks )
+		response.raise_for_status()
+		jsonResponse = response.json()
+		return jsonResponse
+
+	def GetMyBookList( self ) -> dict:
+		# There is also "library_items" but that is paged and gives back less info (even with the embed=ProductMetadata
+		# query parameter set).
+
+		url = self.InitializationSettings[ "library_sync" ]
+		headers = Kobo.GetHeaderWithAccessToken()
+		hooks = Kobo.__GetReauthenticationHook()
+
+		response = Globals.Kobo.Session.get( url, headers = headers, hooks = hooks )
+		response.raise_for_status()
+		bookList = response.json()
+
+		return bookList
+
+	def __GetContentAccessBook( self, productId: str, displayProfile: str ) -> dict:
+		url = self.InitializationSettings[ "content_access_book" ].replace( "{ProductId}", productId )
+		params = { "DisplayProfile": displayProfile }
+		headers = Kobo.GetHeaderWithAccessToken()
+		hooks = Kobo.__GetReauthenticationHook()
+
+		response = self.Session.get( url, params = params, headers = headers, hooks = hooks )
+		response.raise_for_status()
+		jsonResponse = response.json()
+		return jsonResponse
+
+	@staticmethod
+	def __GetContentKeys( contentAccessBookResponse: dict ) -> Dict[ str, str ]:
+		jsonContentKeys = contentAccessBookResponse.get( "ContentKeys" )
+		if jsonContentKeys is None:
+			return {}
+
+		contentKeys = {}
+		for contentKey in jsonContentKeys:
+			contentKeys[ contentKey[ "Name" ] ] = contentKey[ "Value" ]
+		return contentKeys
+
+	@staticmethod
+	def __GetDownloadInfo( productId: str, contentAccessBookResponse: dict ) -> Tuple[ str, bool ]:
+		jsonContentUrls = contentAccessBookResponse.get( "ContentUrls" )
+		if jsonContentUrls is None:
+			raise KoboException( "Content URL can't be found for product '%s'." % productId )
+
+		for jsonContentUrl in jsonContentUrls:
+			if ( jsonContentUrl[ "DRMType" ] == "KDRM" or jsonContentUrl[ "DRMType" ] == "SignedNoDrm" ) and \
+				( jsonContentUrl[ "UrlFormat" ] == "EPUB3" or jsonContentUrl[ "UrlFormat" ] == "KEPUB" ):
+				hasDrm = jsonContentUrl[ "DRMType" ] == "KDRM"
+				return jsonContentUrl[ "DownloadUrl" ], hasDrm
+
+		raise KoboException( "Download URL for supported formats can't be found for product '%s'." % productId )
+
+	def __DownloadToFile( self, url, outputPath: str ) -> None:
+		response = self.Session.get( url, stream = True )
+		response.raise_for_status()
+		with open( outputPath, "wb" ) as f:
+			for chunk in response.iter_content( chunk_size = 1024 * 256 ):
+				f.write( chunk )
+
+	def Download( self, productId: str, displayProfile: str, outputPath: str ) -> None:
+		jsonResponse = self.__GetContentAccessBook( productId, displayProfile )
+		contentKeys = Kobo.__GetContentKeys( jsonResponse )
+		downloadUrl, hasDrm = Kobo.__GetDownloadInfo( productId, jsonResponse )
+
+		temporaryOutputPath = outputPath + ".downloading"
+
+		try:
+			self.__DownloadToFile( downloadUrl, temporaryOutputPath )
+
+			if hasDrm:
+				drmRemover = KoboDrmRemover( Globals.Settings.DeviceId, Globals.Settings.UserId )
+				drmRemover.RemoveDrm( temporaryOutputPath, outputPath, contentKeys )
+				os.remove( temporaryOutputPath )
+			else:
+				os.rename( temporaryOutputPath, outputPath )
+		except:
+			if os.path.isfile( temporaryOutputPath ):
+				os.remove( temporaryOutputPath )
+			if os.path.isfile( outputPath ):
+				os.remove( outputPath )
+
+			raise

+ 38 - 0
kobo-book-downloader/KoboDrmRemover.py

@@ -0,0 +1,38 @@
+from Crypto.Cipher import AES
+from Crypto.Util import Padding
+
+from typing import Dict
+import base64
+import binascii
+import hashlib
+import zipfile
+
+# Based on obok.py by Physisticated.
+class KoboDrmRemover:
+	def __init__( self, deviceId: str, userId: str ):
+		self.DeviceIdUserIdKey = KoboDrmRemover.__MakeDeviceIdUserIdKey( deviceId, userId )
+
+	@staticmethod
+	def __MakeDeviceIdUserIdKey( deviceId: str, userId: str ) -> str:
+		deviceIdUserId = ( deviceId + userId ).encode()
+		key = hashlib.sha256( deviceIdUserId ).hexdigest()
+		return binascii.a2b_hex( key[ 32: ] )
+
+	def __DecryptContents( self, contents: bytes, contentKeyBase64: str ) -> bytes:
+		contentKey = base64.b64decode( contentKeyBase64 )
+		keyAes = AES.new( self.DeviceIdUserIdKey, AES.MODE_ECB )
+		decryptedContentKey = keyAes.decrypt( contentKey )
+
+		contentAes = AES.new( decryptedContentKey, AES.MODE_ECB )
+		decryptedContents = contentAes.decrypt( contents )
+		return Padding.unpad( decryptedContents, AES.block_size, "pkcs7" )
+
+	def RemoveDrm( self, inputPath: str, outputPath: str, contentKeys: Dict[ str, str ] ) -> None:
+		with zipfile.ZipFile( inputPath, "r" ) as inputZip:
+			with zipfile.ZipFile( outputPath, "w", zipfile.ZIP_DEFLATED ) as outputZip:
+				for filename in inputZip.namelist():
+					contents = inputZip.read( filename )
+					contentKeyBase64 = contentKeys.get( filename, None )
+					if contentKeyBase64 is not None:
+						contents = self.__DecryptContents( contents, contentKeyBase64 )
+					outputZip.writestr( filename, contents )

+ 60 - 0
kobo-book-downloader/Settings.py

@@ -0,0 +1,60 @@
+import json
+import os
+
+class Settings:
+	def __init__( self ):
+		self.DeviceId = ""
+		self.AccessToken = ""
+		self.RefreshToken = ""
+		self.UserId = ""
+		self.UserKey = ""
+		self.SettingsFilePath = Settings.__GetCacheFilePath()
+
+		self.Load()
+
+	def AreAuthenticationSettingsSet( self ):
+		return len( self.DeviceId) > 0 and len( self.AccessToken ) > 0 and len( self.RefreshToken ) > 0
+
+	def IsLoggedIn( self ):
+		return len( self.UserId ) > 0 and len( self.UserKey ) > 0
+
+	def Load( self ) -> None:
+		if not os.path.isfile( self.SettingsFilePath ):
+			return
+
+		with open( self.SettingsFilePath, "r" ) as f:
+			jsonText = f.read()
+			jsonObject = json.loads( jsonText )
+			self.__LoadFromJson( jsonObject )
+
+	def Save( self ) -> None:
+		with open( self.SettingsFilePath, "w" ) as f:
+			jsonObject = self.__SaveToJson()
+			f.write( json.dumps( jsonObject, indent = 4 ) )
+
+	def __SaveToJson( self ) -> dict:
+		return {
+			"AccessToken": self.AccessToken,
+			"DeviceId": self.DeviceId,
+			"RefreshToken": self.RefreshToken,
+			"UserId": self.UserId,
+			"UserKey": self.UserKey
+		}
+
+	def __LoadFromJson( self, jsonMap: dict ) -> None:
+		self.AccessToken = jsonMap.get( "AccessToken", self.AccessToken )
+		self.DeviceId = jsonMap.get( "DeviceId", self.DeviceId )
+		self.RefreshToken = jsonMap.get( "RefreshToken", self.RefreshToken )
+		self.UserId = jsonMap.get( "UserId", self.UserId )
+		self.UserKey = jsonMap.get( "UserKey", self.UserKey )
+
+	@staticmethod
+	def __GetCacheFilePath() -> str:
+		cacheHome = os.environ.get( "XDG_CONFIG_HOME." )
+		if ( cacheHome is None ) or ( not os.path.isdir( cacheHome ) ):
+			home = os.path.expanduser( "~" )
+			cacheHome = os.path.join( home, ".config" )
+			if not os.path.isdir( cacheHome ):
+				cacheHome = home
+
+		return os.path.join( cacheHome, "kobo-book-downloader.json" )

+ 53 - 0
kobo-book-downloader/__main__.py

@@ -0,0 +1,53 @@
+from Commands import Commands
+from Globals import Globals
+from Kobo import Kobo, KoboException
+from Settings import Settings
+
+import argparse
+
+def Initialize():
+	Globals.Kobo = Kobo()
+	Globals.Settings = Settings()
+
+	if not Globals.Settings.AreAuthenticationSettingsSet():
+		Globals.Kobo.AuthenticateDevice()
+
+	Globals.Kobo.LoadInitializationSettings()
+
+	if not Globals.Settings.IsLoggedIn():
+		email = input( "Waiting for your input. You can use Shift+Insert to paste from the clipboard. Ctrl+C aborts the program.\n\nKobo e-mail: " )
+		password = input( "Kobo password: " )
+		Globals.Kobo.Login( email, password )
+
+def Main() -> None:
+	argumentParser = argparse.ArgumentParser( add_help = False )
+	argumentParser.add_argument( "--help", "-h", default = False, action = "store_true" )
+	subparsers = argumentParser.add_subparsers( dest = "Command", title = "commands", metavar = "command" )
+	getParser = subparsers.add_parser( "get", help = "Download book" )
+	getParser.add_argument( "OutputPath", metavar = "output-path", help = "If the output path is a directory then the file will be named automatically." )
+	getParser.add_argument( "RevisionId", metavar = "book-id", nargs = "?", help = "The identifier of the book" )
+	getParser.add_argument( "--all", default = False, action = "store_true", help = "Download all my books" )
+	infoParser = subparsers.add_parser( "info", help = "Show the location of the program's configuration file" )
+	listParser = subparsers.add_parser( "list", help = "List unread books" )
+	listParser.add_argument( "--all", default = False, action = "store_true", help = "List read books too" )
+	arguments = argumentParser.parse_args()
+
+	if arguments.Command is None:
+		Commands.ShowUsage()
+		return
+
+	Initialize()
+
+	if arguments.Command == "get":
+		Commands.GetBookOrBooks( arguments.RevisionId, arguments.OutputPath, arguments.all )
+	elif arguments.Command == "info":
+		Commands.Info()
+	elif arguments.Command == "list":
+		Commands.ListBooks( arguments.all )
+
+
+if __name__ == '__main__':
+	try:
+		Main()
+	except KoboException as e:
+		print( "ERROR: %s" % e )

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+colorama
+pycryptodome
+requests