Commands.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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 None:
  149. continue
  150. # Skip saved previews.
  151. if bookEntitlement.get( "Accessibility" ) == "Preview":
  152. continue
  153. # Skip refunded books.
  154. if bookEntitlement.get( "IsLocked" ):
  155. continue
  156. if ( not listAll ) and Commands.__IsBookRead( newEntitlement ):
  157. continue
  158. bookMetadata = newEntitlement[ "BookMetadata" ]
  159. book = [ bookMetadata[ "RevisionId" ],
  160. bookMetadata[ "Title" ],
  161. Commands.__GetBookAuthor( bookMetadata ),
  162. Commands.__IsBookArchived( newEntitlement ) ]
  163. rows.append( book )
  164. rows = sorted( rows, key = lambda columns: columns[ 1 ].lower() )
  165. return rows
  166. @staticmethod
  167. def ListBooks( listAll: bool ) -> None:
  168. rows = Commands.__GetBookList( listAll )
  169. for columns in rows:
  170. revisionId = colorama.Style.DIM + columns[ 0 ] + colorama.Style.RESET_ALL
  171. title = colorama.Style.BRIGHT + columns[ 1 ] + colorama.Style.RESET_ALL
  172. author = columns[ 2 ]
  173. if len( author ) > 0:
  174. title += " by " + author
  175. archived = columns[ 3 ]
  176. if archived:
  177. title += colorama.Fore.LIGHTYELLOW_EX + " (archived)" + colorama.Fore.RESET
  178. print( "%s \t %s" % ( revisionId, title ) )
  179. @staticmethod
  180. def __ListBooksToPickFrom( rows: list ) -> None:
  181. longestIndex = len( "%d" % len( rows ) )
  182. for index, columns in enumerate( rows ):
  183. alignedIndexText = str( index + 1 ).rjust( longestIndex, ' ' )
  184. title = colorama.Style.BRIGHT + columns[ 1 ] + colorama.Style.RESET_ALL
  185. author = columns[ 2 ]
  186. if len( author ) > 0:
  187. title += " by " + author
  188. archived = columns[ 3 ]
  189. if archived:
  190. title += colorama.Fore.LIGHTYELLOW_EX + " (archived)" + colorama.Fore.RESET
  191. print( "%s. %s" % ( alignedIndexText, title ) )
  192. @staticmethod
  193. def __GetPickedBookRows( rows: list ) -> list:
  194. print( """\nEnter the number of the book(s) to download. Use comma or space to list multiple. Enter "all" to download all of them.""" )
  195. indexText = input( "Books: " )
  196. if indexText == "all":
  197. return rows
  198. indexList = indexText.replace( " ", "," ).split( "," )
  199. rowsToDownload = []
  200. for indexText in indexList:
  201. try:
  202. index = int( indexText.strip() ) - 1
  203. if index >= 0 and index < len( rows ):
  204. rowsToDownload.append( rows[ index ] )
  205. except Exception:
  206. pass
  207. return rowsToDownload
  208. @staticmethod
  209. def __DownloadPickedBooks( outputPath: str, rows: list ) -> None:
  210. for columns in rows:
  211. revisionId = columns[ 0 ]
  212. title = columns[ 1 ]
  213. author = columns[ 2 ]
  214. archived = columns[ 3 ]
  215. if archived:
  216. if len( author ) > 0:
  217. title += " by " + author
  218. print( colorama.Fore.LIGHTYELLOW_EX + ( "Skipping archived book %s." % title ) + colorama.Fore.RESET )
  219. else:
  220. Commands.GetBookOrBooks( revisionId, outputPath, False )
  221. @staticmethod
  222. def PickBooks( outputPath: str, listAll: bool ) -> None:
  223. rows = Commands.__GetBookList( listAll )
  224. Commands.__ListBooksToPickFrom( rows )
  225. rowsToDownload = Commands.__GetPickedBookRows( rows )
  226. Commands.__DownloadPickedBooks( outputPath, rowsToDownload )
  227. @staticmethod
  228. def ListWishListedBooks() -> None:
  229. rows = []
  230. wishList = Globals.Kobo.GetMyWishList()
  231. for wishListEntry in wishList:
  232. productMetadata = wishListEntry.get( "ProductMetadata" )
  233. if productMetadata is None:
  234. continue
  235. book = productMetadata.get( "Book" )
  236. if book is None:
  237. continue
  238. title = colorama.Style.BRIGHT + book[ "Title" ] + colorama.Style.RESET_ALL
  239. author = Commands.__GetBookAuthor( book )
  240. isbn = book.get( "ISBN", "" )
  241. row = title
  242. if len( author ) > 0:
  243. row += " by " + author
  244. if len( isbn ) > 0:
  245. row += " (ISBN: %s)" % isbn
  246. rows.append( row )
  247. rows = sorted( rows, key = lambda row: row.lower() )
  248. print( "\n".join( rows ) )
  249. @staticmethod
  250. def Info():
  251. print( "The configuration file is located at:\n%s" % Globals.Settings.SettingsFilePath )