Commands.py 9.3 KB

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