Commands.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. from Globals import Globals
  2. from Kobo import Kobo, KoboException
  3. import colorama
  4. import os
  5. class Commands:
  6. # It wasn't possible to format the main help message to my liking, so using a custom one.
  7. # This was the most annoying:
  8. #
  9. # commands:
  10. # command <-- absolutely unneeded text
  11. # get List unread books
  12. # list Get book
  13. #
  14. # See https://stackoverflow.com/questions/13423540/ and https://stackoverflow.com/questions/11070268/
  15. @staticmethod
  16. def ShowUsage():
  17. usage = \
  18. """Kobo book downloader and DRM remover
  19. Usage:
  20. kobo-book-downloader [--help] command ...
  21. Commands:
  22. get Download book
  23. info Show the location of the configuration file
  24. list List your books
  25. pick Download books using interactive selection
  26. wishlist List your wish listed books
  27. Optional arguments:
  28. -h, --help Show this help message and exit
  29. Examples:
  30. kobo-book-downloader get /dir/book.epub 01234567-89ab-cdef-0123-456789abcdef Download book
  31. kobo-book-downloader get /dir/ 01234567-89ab-cdef-0123-456789abcdef Download book and name the file automatically
  32. kobo-book-downloader get /dir/ --all Download all your books
  33. kobo-book-downloader info Show the location of the program's configuration file
  34. kobo-book-downloader list List your unread books
  35. kobo-book-downloader list --all List all your books
  36. kobo-book-downloader list --help Get additional help for the list command (it works for get and pick too)
  37. kobo-book-downloader pick /dir/ Interactively select unread books to download
  38. kobo-book-downloader pick /dir/ --all Interactively select books to download
  39. kobo-book-downloader wishlist List your wish listed books"""
  40. print( usage )
  41. @staticmethod
  42. def __GetBookAuthor( book: dict ) -> str:
  43. contributors = book.get( "ContributorRoles" )
  44. authors = []
  45. for contributor in contributors:
  46. role = contributor.get( "Role" )
  47. if role == "Author":
  48. authors.append( contributor[ "Name" ] )
  49. # Unfortunately the role field is not filled out in the data returned by the "library_sync" endpoint, so we only
  50. # use the first author and hope for the best. Otherwise we would get non-main authors too. For example Christopher
  51. # Buckley beside Joseph Heller for the -- terrible -- novel Catch-22.
  52. if len( authors ) == 0 and len( contributors ) > 0:
  53. authors.append( contributors[ 0 ][ "Name" ] )
  54. return " & ".join( authors )
  55. @staticmethod
  56. def __SanitizeFileName( fileName: str ) -> str:
  57. result = ""
  58. for c in fileName:
  59. if c.isalnum() or " ,;.!(){}[]#$'-+@_".find( c ) >= 0:
  60. result += c
  61. result = result.strip( " ." )
  62. result = result[ :100 ] # Limit the length -- mostly because of Windows. It would be better to do it on the full path using MAX_PATH.
  63. return result
  64. @staticmethod
  65. def __MakeFileNameForBook( book: dict ) -> str:
  66. fileName = ""
  67. author = Commands.__GetBookAuthor( book )
  68. if len( author ) > 0:
  69. fileName = author + " - "
  70. fileName += book[ "Title" ]
  71. fileName = Commands.__SanitizeFileName( fileName )
  72. fileName += ".epub"
  73. return fileName
  74. @staticmethod
  75. def __IsBookArchived( newEntitlement: dict ) -> bool:
  76. bookEntitlement = newEntitlement.get( "BookEntitlement" )
  77. if bookEntitlement is None:
  78. return False
  79. isRemoved = bookEntitlement.get( "IsRemoved" )
  80. if isRemoved is None:
  81. return False
  82. return isRemoved
  83. @staticmethod
  84. def __GetBook( revisionId: str, outputPath: str ) -> None:
  85. if os.path.isdir( outputPath ):
  86. book = Globals.Kobo.GetBookInfo( revisionId )
  87. fileName = Commands.__MakeFileNameForBook( book )
  88. outputPath = os.path.join( outputPath, fileName )
  89. else:
  90. parentPath = os.path.dirname( outputPath )
  91. if not os.path.isdir( parentPath ):
  92. raise KoboException( "The parent directory ('%s') of the output file must exist." % parentPath )
  93. print( "Downloading book to '%s'." % outputPath )
  94. Globals.Kobo.Download( revisionId, Kobo.DisplayProfile, outputPath )
  95. @staticmethod
  96. def __GetAllBooks( outputPath: str ) -> None:
  97. if not os.path.isdir( outputPath ):
  98. raise KoboException( "The output path must be a directory when downloading all books." )
  99. bookList = Globals.Kobo.GetMyBookList()
  100. for entitlement in bookList:
  101. newEntitlement = entitlement.get( "NewEntitlement" )
  102. if newEntitlement is None:
  103. continue
  104. bookMetadata = newEntitlement[ "BookMetadata" ]
  105. fileName = Commands.__MakeFileNameForBook( bookMetadata )
  106. outputFilePath = os.path.join( outputPath, fileName )
  107. # Skip archived books.
  108. if Commands.__IsBookArchived( newEntitlement ):
  109. title = bookMetadata[ "Title" ]
  110. author = Commands.__GetBookAuthor( bookMetadata )
  111. if len( author ) > 0:
  112. title += " by " + author
  113. print( colorama.Fore.LIGHTYELLOW_EX + ( "Skipping archived book %s." % title ) + colorama.Fore.RESET )
  114. continue
  115. print( "Downloading book to '%s'." % outputFilePath )
  116. Globals.Kobo.Download( bookMetadata[ "RevisionId" ], Kobo.DisplayProfile, outputFilePath )
  117. @staticmethod
  118. def GetBookOrBooks( revisionId: str, outputPath: str, getAll: bool ) -> None:
  119. revisionIdIsSet = ( revisionId is not None ) and len( revisionId ) > 0
  120. if getAll:
  121. if revisionIdIsSet:
  122. raise KoboException( "Got unexpected book identifier parameter ('%s')." % revisionId )
  123. Commands.__GetAllBooks( outputPath )
  124. else:
  125. if not revisionIdIsSet:
  126. raise KoboException( "Missing book identifier parameter. Did you mean to use the --all parameter?" )
  127. Commands.__GetBook( revisionId, outputPath )
  128. @staticmethod
  129. def __IsBookRead( newEntitlement: dict ) -> bool:
  130. readingState = newEntitlement.get( "ReadingState" )
  131. if readingState is None:
  132. return False
  133. statusInfo = readingState.get( "StatusInfo" )
  134. if statusInfo is None:
  135. return False
  136. status = statusInfo.get( "Status" )
  137. return status == "Finished"
  138. @staticmethod
  139. def __GetBookList( listAll: bool ) -> list:
  140. bookList = Globals.Kobo.GetMyBookList()
  141. rows = []
  142. for entitlement in bookList:
  143. newEntitlement = entitlement.get( "NewEntitlement" )
  144. if newEntitlement is None:
  145. continue
  146. bookEntitlement = newEntitlement.get( "BookEntitlement" )
  147. if bookEntitlement is not None:
  148. # Skip saved previews.
  149. if bookEntitlement.get( "Accessibility" ) == "Preview":
  150. continue
  151. # Skip refunded books.
  152. if bookEntitlement.get( "IsLocked" ):
  153. continue
  154. if ( not listAll ) and Commands.__IsBookRead( newEntitlement ):
  155. continue
  156. bookMetadata = newEntitlement[ "BookMetadata" ]
  157. book = [ bookMetadata[ "RevisionId" ],
  158. bookMetadata[ "Title" ],
  159. Commands.__GetBookAuthor( bookMetadata ),
  160. Commands.__IsBookArchived( newEntitlement ) ]
  161. rows.append( book )
  162. rows = sorted( rows, key = lambda columns: columns[ 1 ].lower() )
  163. return rows
  164. @staticmethod
  165. def ListBooks( listAll: bool ) -> None:
  166. rows = Commands.__GetBookList( listAll )
  167. for columns in rows:
  168. revisionId = colorama.Style.DIM + columns[ 0 ] + colorama.Style.RESET_ALL
  169. title = colorama.Style.BRIGHT + columns[ 1 ] + colorama.Style.RESET_ALL
  170. author = columns[ 2 ]
  171. if len( author ) > 0:
  172. title += " by " + author
  173. archived = columns[ 3 ]
  174. if archived:
  175. title += colorama.Fore.LIGHTYELLOW_EX + " (archived)" + colorama.Fore.RESET
  176. print( "%s \t %s" % ( revisionId, title ) )
  177. @staticmethod
  178. def __ListBooksToPickFrom( rows: list ) -> None:
  179. longestIndex = len( "%d" % len( rows ) )
  180. for index, columns in enumerate( rows ):
  181. alignedIndexText = str( index + 1 ).rjust( longestIndex, ' ' )
  182. title = colorama.Style.BRIGHT + columns[ 1 ] + colorama.Style.RESET_ALL
  183. author = columns[ 2 ]
  184. if len( author ) > 0:
  185. title += " by " + author
  186. archived = columns[ 3 ]
  187. if archived:
  188. title += colorama.Fore.LIGHTYELLOW_EX + " (archived)" + colorama.Fore.RESET
  189. print( "%s. %s" % ( alignedIndexText, title ) )
  190. @staticmethod
  191. def __GetPickedBookRows( rows: list ) -> list:
  192. print( """\nEnter the number of the book(s) to download. Use comma or space to list multiple. Enter "all" to download all of them.""" )
  193. indexText = input( "Books: " )
  194. if indexText == "all":
  195. return rows
  196. indexList = indexText.replace( " ", "," ).split( "," )
  197. rowsToDownload = []
  198. for indexText in indexList:
  199. try:
  200. index = int( indexText.strip() ) - 1
  201. if index >= 0 and index < len( rows ):
  202. rowsToDownload.append( rows[ index ] )
  203. except Exception:
  204. pass
  205. return rowsToDownload
  206. @staticmethod
  207. def __DownloadPickedBooks( outputPath: str, rows: list ) -> None:
  208. for columns in rows:
  209. revisionId = columns[ 0 ]
  210. title = columns[ 1 ]
  211. author = columns[ 2 ]
  212. archived = columns[ 3 ]
  213. if archived:
  214. if len( author ) > 0:
  215. title += " by " + author
  216. print( colorama.Fore.LIGHTYELLOW_EX + ( "Skipping archived book %s." % title ) + colorama.Fore.RESET )
  217. else:
  218. Commands.GetBookOrBooks( revisionId, outputPath, False )
  219. @staticmethod
  220. def PickBooks( outputPath: str, listAll: bool ) -> None:
  221. rows = Commands.__GetBookList( listAll )
  222. Commands.__ListBooksToPickFrom( rows )
  223. rowsToDownload = Commands.__GetPickedBookRows( rows )
  224. Commands.__DownloadPickedBooks( outputPath, rowsToDownload )
  225. @staticmethod
  226. def ListWishListedBooks() -> None:
  227. rows = []
  228. wishList = Globals.Kobo.GetMyWishList()
  229. for wishListEntry in wishList:
  230. productMetadata = wishListEntry.get( "ProductMetadata" )
  231. if productMetadata is None:
  232. continue
  233. book = productMetadata.get( "Book" )
  234. if book is None:
  235. continue
  236. title = colorama.Style.BRIGHT + book[ "Title" ] + colorama.Style.RESET_ALL
  237. author = Commands.__GetBookAuthor( book )
  238. isbn = book.get( "ISBN", "" )
  239. row = title
  240. if len( author ) > 0:
  241. row += " by " + author
  242. if len( isbn ) > 0:
  243. row += " (ISBN: %s)" % isbn
  244. rows.append( row )
  245. rows = sorted( rows, key = lambda row: row.lower() )
  246. print( "\n".join( rows ) )
  247. @staticmethod
  248. def Info():
  249. print( "The configuration file is located at:\n%s" % Globals.Settings.SettingsFilePath )