Commands.py 10 KB

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