codex 1.4.0a1__py3-none-any.whl → 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of codex might be problematic. Click here for more details.

Files changed (259) hide show
  1. codex/config_default.yaml +12 -4
  2. codex/db_functions.py +4 -2
  3. codex/integrity.py +17 -6
  4. codex/librarian/covers/create.py +6 -8
  5. codex/librarian/importer/aggregate_metadata.py +75 -41
  6. codex/librarian/importer/clean_metadata.py +30 -7
  7. codex/librarian/importer/create_fks.py +154 -55
  8. codex/librarian/importer/deleted.py +11 -2
  9. codex/librarian/importer/failed_imports.py +41 -5
  10. codex/librarian/importer/importerd.py +34 -11
  11. codex/librarian/importer/link_comics.py +54 -31
  12. codex/librarian/importer/moved.py +55 -11
  13. codex/librarian/importer/query_fks.py +210 -48
  14. codex/librarian/importer/tasks.py +7 -7
  15. codex/librarian/janitor/cleanup.py +17 -5
  16. codex/librarian/librariand.py +10 -0
  17. codex/librarian/watchdog/events.py +11 -14
  18. codex/librarian/watchdog/observers.py +5 -1
  19. codex/logger/loggerd.py +7 -3
  20. codex/logger/logging.py +1 -1
  21. codex/migrations/0024_comic_gtin_comic_story_arc_number.py +24 -0
  22. codex/migrations/0025_add_story_arc_number.py +83 -0
  23. codex/models.py +21 -11
  24. codex/search/backend.py +1 -1
  25. codex/search/indexes.py +1 -1
  26. codex/serializers/browser.py +1 -0
  27. codex/serializers/metadata.py +5 -1
  28. codex/serializers/models.py +16 -1
  29. codex/serializers/opds/v1.py +1 -0
  30. codex/serializers/opds/v2.py +5 -2
  31. codex/serializers/reader.py +55 -16
  32. codex/settings/settings.py +1 -1
  33. codex/static_root/assets/admin-12749881.ef0f50bac290.js +41 -0
  34. codex/static_root/assets/admin-12749881.ef0f50bac290.js.br +0 -0
  35. codex/static_root/assets/admin-12749881.ef0f50bac290.js.gz +0 -0
  36. codex/static_root/assets/admin-12749881.js +41 -0
  37. codex/static_root/assets/admin-12749881.js.br +0 -0
  38. codex/static_root/assets/admin-12749881.js.gz +0 -0
  39. codex/static_root/assets/admin-beda768d.a614eee46307.css +1 -0
  40. codex/static_root/assets/admin-beda768d.a614eee46307.css.br +0 -0
  41. codex/static_root/assets/admin-beda768d.a614eee46307.css.gz +0 -0
  42. codex/static_root/assets/admin-beda768d.css +1 -0
  43. codex/static_root/assets/admin-beda768d.css.br +0 -0
  44. codex/static_root/assets/admin-beda768d.css.gz +0 -0
  45. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css +1 -0
  46. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.br +0 -0
  47. codex/static_root/assets/admin-drawer-panel-41c225cc.3f84583b435b.css.gz +0 -0
  48. codex/static_root/assets/admin-drawer-panel-41c225cc.css +1 -0
  49. codex/static_root/assets/admin-drawer-panel-41c225cc.css.br +0 -0
  50. codex/static_root/assets/admin-drawer-panel-41c225cc.css.gz +0 -0
  51. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js +1 -0
  52. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.br +0 -0
  53. codex/static_root/assets/admin-drawer-panel-522f1e6c.089d70878270.js.gz +0 -0
  54. codex/static_root/assets/admin-drawer-panel-522f1e6c.js +1 -0
  55. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.br +0 -0
  56. codex/static_root/assets/admin-drawer-panel-522f1e6c.js.gz +0 -0
  57. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css +1 -0
  58. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.br +0 -0
  59. codex/static_root/assets/browser-7f7d7134.0fe3749b0f2f.css.gz +0 -0
  60. codex/static_root/assets/browser-7f7d7134.css +1 -0
  61. codex/static_root/assets/browser-7f7d7134.css.br +0 -0
  62. codex/static_root/assets/browser-7f7d7134.css.gz +0 -0
  63. codex/static_root/assets/browser-af622672.d51aca96d64d.js +1 -0
  64. codex/static_root/assets/browser-af622672.d51aca96d64d.js.br +0 -0
  65. codex/static_root/assets/browser-af622672.d51aca96d64d.js.gz +0 -0
  66. codex/static_root/assets/browser-af622672.js +1 -0
  67. codex/static_root/assets/browser-af622672.js.br +0 -0
  68. codex/static_root/assets/browser-af622672.js.gz +0 -0
  69. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js +1 -0
  70. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.br +0 -0
  71. codex/static_root/assets/http-error-5e17b794.77ceeb2d4641.js.gz +0 -0
  72. codex/static_root/assets/http-error-5e17b794.js +1 -0
  73. codex/static_root/assets/http-error-5e17b794.js.br +0 -0
  74. codex/static_root/assets/http-error-5e17b794.js.gz +0 -0
  75. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js +1 -0
  76. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.br +0 -0
  77. codex/static_root/assets/main-9e76a4c3.6844a407d14c.js.gz +0 -0
  78. codex/static_root/assets/main-9e76a4c3.js +1 -0
  79. codex/static_root/assets/main-9e76a4c3.js.br +0 -0
  80. codex/static_root/assets/main-9e76a4c3.js.gz +0 -0
  81. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js +1 -0
  82. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.br +0 -0
  83. codex/static_root/assets/metadata-dialog-62c29ce0.8418785c0453.js.gz +0 -0
  84. codex/static_root/assets/metadata-dialog-62c29ce0.js +1 -0
  85. codex/static_root/assets/metadata-dialog-62c29ce0.js.br +0 -0
  86. codex/static_root/assets/metadata-dialog-62c29ce0.js.gz +0 -0
  87. codex/static_root/assets/{metadata-dialog-785c4cfc.694a251cda37.css → metadata-dialog-cb306ffd.cc304996d7bb.css} +1 -1
  88. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.br +0 -0
  89. codex/static_root/assets/metadata-dialog-cb306ffd.cc304996d7bb.css.gz +0 -0
  90. codex/static_root/assets/{metadata-dialog-785c4cfc.css → metadata-dialog-cb306ffd.css} +1 -1
  91. codex/static_root/assets/metadata-dialog-cb306ffd.css.br +0 -0
  92. codex/static_root/assets/metadata-dialog-cb306ffd.css.gz +0 -0
  93. codex/static_root/assets/{page-pdf-c603e996.ab2d147c9ae1.js → page-pdf-157ba97e.613d7c2beb77.js} +61 -51
  94. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.br +0 -0
  95. codex/static_root/assets/page-pdf-157ba97e.613d7c2beb77.js.gz +0 -0
  96. codex/static_root/assets/{page-pdf-c603e996.js → page-pdf-157ba97e.js} +61 -51
  97. codex/static_root/assets/page-pdf-157ba97e.js.br +0 -0
  98. codex/static_root/assets/page-pdf-157ba97e.js.gz +0 -0
  99. codex/static_root/assets/reader-36266549.0b2cf1291f27.js +1 -0
  100. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.br +0 -0
  101. codex/static_root/assets/reader-36266549.0b2cf1291f27.js.gz +0 -0
  102. codex/static_root/assets/reader-36266549.js +1 -0
  103. codex/static_root/assets/reader-36266549.js.br +0 -0
  104. codex/static_root/assets/reader-36266549.js.gz +0 -0
  105. codex/static_root/assets/reader-7f004141.506eecc6954b.css +1 -0
  106. codex/static_root/assets/reader-7f004141.506eecc6954b.css.br +0 -0
  107. codex/static_root/assets/reader-7f004141.506eecc6954b.css.gz +0 -0
  108. codex/static_root/assets/reader-7f004141.css +1 -0
  109. codex/static_root/assets/reader-7f004141.css.br +0 -0
  110. codex/static_root/assets/reader-7f004141.css.gz +0 -0
  111. codex/static_root/js/choices.8c58714cf5b2.json +1 -0
  112. codex/static_root/js/choices.8c58714cf5b2.json.br +5 -0
  113. codex/static_root/js/choices.8c58714cf5b2.json.gz +0 -0
  114. codex/static_root/js/choices.json +1 -1
  115. codex/static_root/js/choices.json.br +0 -0
  116. codex/static_root/js/choices.json.gz +0 -0
  117. codex/static_root/{manifest.c0e270b2e6b6.json → manifest.d2f93a519ada.json} +32 -32
  118. codex/static_root/manifest.d2f93a519ada.json.br +0 -0
  119. codex/static_root/manifest.d2f93a519ada.json.gz +0 -0
  120. codex/static_root/manifest.json +32 -32
  121. codex/static_root/manifest.json.br +0 -0
  122. codex/static_root/manifest.json.gz +0 -0
  123. codex/static_root/staticfiles.json +1 -1
  124. codex/templates/headers-script-globals.html +1 -1
  125. codex/templates/{opds → opds_v1}/index.xml +3 -1
  126. codex/templates/{opds/opensearch.xml → opds_v1/opensearch_v1.xml} +1 -1
  127. codex/templates/search/indexes/codex/comic_text.txt +2 -2
  128. codex/urls/converters.py +1 -1
  129. codex/urls/opds/authentication.py +1 -1
  130. codex/urls/opds/root.py +8 -12
  131. codex/urls/opds/v1.py +12 -5
  132. codex/urls/opds/v2.py +2 -2
  133. codex/views/bookmark.py +2 -2
  134. codex/views/browser/base.py +23 -7
  135. codex/views/browser/browser.py +51 -41
  136. codex/views/browser/browser_annotations.py +159 -50
  137. codex/views/browser/browser_order_by.py +50 -106
  138. codex/views/browser/choices.py +75 -38
  139. codex/views/browser/filters/bookmark.py +6 -9
  140. codex/views/browser/filters/field.py +9 -6
  141. codex/views/browser/filters/group.py +12 -27
  142. codex/views/browser/filters/search.py +5 -10
  143. codex/views/browser/metadata.py +44 -19
  144. codex/views/download.py +1 -1
  145. codex/views/frontend.py +2 -3
  146. codex/views/mixins.py +15 -2
  147. codex/views/opds/const.py +8 -1
  148. codex/views/opds/util.py +37 -1
  149. codex/views/opds/v1/__init__.py +1 -1
  150. codex/views/opds/v1/data.py +21 -0
  151. codex/views/opds/v1/entry/__init__.py +1 -0
  152. codex/views/opds/v1/entry/data.py +23 -0
  153. codex/views/opds/v1/entry/entry.py +151 -0
  154. codex/views/opds/v1/entry/links.py +135 -0
  155. codex/views/opds/v1/facets.py +190 -0
  156. codex/views/opds/v1/feed.py +199 -0
  157. codex/views/opds/v1/links.py +198 -0
  158. codex/views/opds/{opensearch.py → v1/opensearch_v1.py} +3 -3
  159. codex/views/opds/v2/__init__.py +1 -1
  160. codex/views/opds/v2/const.py +10 -2
  161. codex/views/opds/v2/feed.py +82 -21
  162. codex/views/opds/v2/links.py +1 -1
  163. codex/views/opds/v2/publications.py +1 -1
  164. codex/views/opds/v2/top_links.py +1 -1
  165. codex/views/reader/page.py +6 -7
  166. codex/views/reader/reader.py +191 -61
  167. codex/views/session.py +2 -1
  168. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/METADATA +10 -41
  169. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/RECORD +172 -170
  170. codex/librarian/importer/db_ops.py +0 -251
  171. codex/pdf.py +0 -115
  172. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js +0 -48
  173. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js.br +0 -0
  174. codex/static_root/assets/admin-75c007ce.199fccf24c8d.js.gz +0 -0
  175. codex/static_root/assets/admin-75c007ce.js +0 -48
  176. codex/static_root/assets/admin-75c007ce.js.br +0 -0
  177. codex/static_root/assets/admin-75c007ce.js.gz +0 -0
  178. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css +0 -1
  179. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css.br +0 -0
  180. codex/static_root/assets/admin-848d48b1.5de8a0c45636.css.gz +0 -0
  181. codex/static_root/assets/admin-848d48b1.css +0 -1
  182. codex/static_root/assets/admin-848d48b1.css.br +0 -0
  183. codex/static_root/assets/admin-848d48b1.css.gz +0 -0
  184. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js +0 -1
  185. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js.br +0 -0
  186. codex/static_root/assets/admin-drawer-panel-a110c068.edf187333272.js.gz +0 -0
  187. codex/static_root/assets/admin-drawer-panel-a110c068.js +0 -1
  188. codex/static_root/assets/admin-drawer-panel-a110c068.js.br +0 -0
  189. codex/static_root/assets/admin-drawer-panel-a110c068.js.gz +0 -0
  190. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css +0 -1
  191. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.br +0 -2
  192. codex/static_root/assets/admin-drawer-panel-cce8c0aa.2c0814fa2a9b.css.gz +0 -0
  193. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css +0 -1
  194. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.br +0 -2
  195. codex/static_root/assets/admin-drawer-panel-cce8c0aa.css.gz +0 -0
  196. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js +0 -1
  197. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js.br +0 -0
  198. codex/static_root/assets/browser-2c2380fd.8b515af7a743.js.gz +0 -0
  199. codex/static_root/assets/browser-2c2380fd.js +0 -1
  200. codex/static_root/assets/browser-2c2380fd.js.br +0 -0
  201. codex/static_root/assets/browser-2c2380fd.js.gz +0 -0
  202. codex/static_root/assets/browser-7325db61.css +0 -1
  203. codex/static_root/assets/browser-7325db61.css.br +0 -0
  204. codex/static_root/assets/browser-7325db61.css.gz +0 -0
  205. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css +0 -1
  206. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.br +0 -0
  207. codex/static_root/assets/browser-7325db61.ed2cfbf8e8ee.css.gz +0 -0
  208. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js +0 -1
  209. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js.br +0 -0
  210. codex/static_root/assets/http-error-402decbe.9ea8de1df13f.js.gz +0 -0
  211. codex/static_root/assets/http-error-402decbe.js +0 -1
  212. codex/static_root/assets/http-error-402decbe.js.br +0 -0
  213. codex/static_root/assets/http-error-402decbe.js.gz +0 -0
  214. codex/static_root/assets/main-a7f327e9.6641fe833335.js +0 -1
  215. codex/static_root/assets/main-a7f327e9.6641fe833335.js.br +0 -0
  216. codex/static_root/assets/main-a7f327e9.6641fe833335.js.gz +0 -0
  217. codex/static_root/assets/main-a7f327e9.js +0 -1
  218. codex/static_root/assets/main-a7f327e9.js.br +0 -0
  219. codex/static_root/assets/main-a7f327e9.js.gz +0 -0
  220. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.br +0 -0
  221. codex/static_root/assets/metadata-dialog-785c4cfc.694a251cda37.css.gz +0 -0
  222. codex/static_root/assets/metadata-dialog-785c4cfc.css.br +0 -0
  223. codex/static_root/assets/metadata-dialog-785c4cfc.css.gz +0 -0
  224. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js +0 -1
  225. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js.br +0 -0
  226. codex/static_root/assets/metadata-dialog-8a0bd8e1.c213b08d582f.js.gz +0 -0
  227. codex/static_root/assets/metadata-dialog-8a0bd8e1.js +0 -1
  228. codex/static_root/assets/metadata-dialog-8a0bd8e1.js.br +0 -0
  229. codex/static_root/assets/metadata-dialog-8a0bd8e1.js.gz +0 -0
  230. codex/static_root/assets/page-pdf-c603e996.ab2d147c9ae1.js.br +0 -0
  231. codex/static_root/assets/page-pdf-c603e996.ab2d147c9ae1.js.gz +0 -0
  232. codex/static_root/assets/page-pdf-c603e996.js.br +0 -0
  233. codex/static_root/assets/page-pdf-c603e996.js.gz +0 -0
  234. codex/static_root/assets/reader-c2965a5f.b011260169f7.js +0 -1
  235. codex/static_root/assets/reader-c2965a5f.b011260169f7.js.br +0 -0
  236. codex/static_root/assets/reader-c2965a5f.b011260169f7.js.gz +0 -0
  237. codex/static_root/assets/reader-c2965a5f.js +0 -1
  238. codex/static_root/assets/reader-c2965a5f.js.br +0 -0
  239. codex/static_root/assets/reader-c2965a5f.js.gz +0 -0
  240. codex/static_root/assets/reader-d8534888.2821de925986.css +0 -1
  241. codex/static_root/assets/reader-d8534888.2821de925986.css.br +0 -0
  242. codex/static_root/assets/reader-d8534888.2821de925986.css.gz +0 -0
  243. codex/static_root/assets/reader-d8534888.css +0 -1
  244. codex/static_root/assets/reader-d8534888.css.br +0 -0
  245. codex/static_root/assets/reader-d8534888.css.gz +0 -0
  246. codex/static_root/js/choices.6bfc2a3d293f.json +0 -1
  247. codex/static_root/js/choices.6bfc2a3d293f.json.br +0 -0
  248. codex/static_root/js/choices.6bfc2a3d293f.json.gz +0 -0
  249. codex/static_root/manifest.c0e270b2e6b6.json.br +0 -0
  250. codex/static_root/manifest.c0e270b2e6b6.json.gz +0 -0
  251. codex/urls/opds/opensearch.py +0 -18
  252. codex/views/opds/v1/browser.py +0 -346
  253. codex/views/opds/v1/entry.py +0 -278
  254. codex/views/opds/v1/start.py +0 -28
  255. codex/views/opds/v1/util.py +0 -162
  256. codex/views/opds/v2/start.py +0 -28
  257. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/LICENSE +0 -0
  258. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/WHEEL +0 -0
  259. {codex-1.4.0a1.dist-info → codex-1.4.1.dist-info}/entry_points.txt +0 -0
@@ -2,10 +2,9 @@
2
2
 
3
3
  So we may safely create the comics next.
4
4
  """
5
+ from itertools import chain
5
6
  from pathlib import Path
6
7
 
7
- from django.db.models.functions import Now
8
-
9
8
  from codex.librarian.importer.status import ImportStatusTypes, status_notify
10
9
  from codex.models import (
11
10
  Creator,
@@ -15,11 +14,14 @@ from codex.models import (
15
14
  Imprint,
16
15
  Publisher,
17
16
  Series,
17
+ StoryArc,
18
+ StoryArcNumber,
18
19
  Volume,
19
20
  )
21
+ from codex.status import Status
20
22
  from codex.threads import QueuedThread
21
23
 
22
- _BULK_UPDATE_FOLDER_MODIFIED_FIELDS = ("stat", "updated_at")
24
+ BULK_UPDATE_FOLDER_MODIFIED_FIELDS = ("stat", "updated_at")
23
25
  _COUNT_FIELDS = {Series: "volume_count", Volume: "issue_count"}
24
26
  _GROUP_UPDATE_FIELDS = {
25
27
  Publisher: ("name",),
@@ -28,7 +30,11 @@ _GROUP_UPDATE_FIELDS = {
28
30
  Volume: ("name", "publisher", "imprint", "series"),
29
31
  }
30
32
  _NAMED_MODEL_UPDATE_FIELDS = ("name",)
31
- _CREATOR_UPDATE_FIELDS = ("person", "role")
33
+
34
+ _CREATE_DICT_UPDATE_FIELDS = {
35
+ Creator: ("person", "role"),
36
+ StoryArcNumber: ("story_arc", "number"),
37
+ }
32
38
 
33
39
 
34
40
  class CreateForeignKeysMixin(QueuedThread):
@@ -63,8 +69,8 @@ class CreateForeignKeysMixin(QueuedThread):
63
69
  @staticmethod
64
70
  def _update_group_obj(group_class, group_param_tuple, count_dict, count_field):
65
71
  """Update group counts for a Series or Volume."""
66
- if count_dict is None:
67
- # TODO this is never none now.
72
+ count = count_dict.get(count_field)
73
+ if count is None:
68
74
  return None
69
75
  search_kwargs = {
70
76
  "publisher__name": group_param_tuple[0],
@@ -76,15 +82,14 @@ class CreateForeignKeysMixin(QueuedThread):
76
82
 
77
83
  obj = group_class.objects.get(**search_kwargs)
78
84
  obj_count = getattr(obj, count_field)
79
- count = count_dict.get(count_field)
80
- if obj_count is None or (count is not None and obj_count < count):
85
+ if obj_count is None or count > obj_count:
81
86
  setattr(obj, count_field, count)
82
87
  else:
83
88
  obj = None
84
89
  return obj
85
90
 
86
91
  @status_notify()
87
- def bulk_group_creator(self, group_tree_counts, group_class, status=None):
92
+ def _bulk_group_creator(self, group_tree_counts, group_class, status=None):
88
93
  """Bulk creates groups."""
89
94
  count = 0
90
95
  if not group_tree_counts:
@@ -103,12 +108,13 @@ class CreateForeignKeysMixin(QueuedThread):
103
108
  count += len(create_groups)
104
109
  self.log.info(f"Created {count} {group_class.__name__}s.")
105
110
  if status:
111
+ status.complete = status.complete or 0
106
112
  status.complete += count
107
113
  self.status_controller.update(status)
108
114
  return count
109
115
 
110
116
  @status_notify()
111
- def bulk_group_updater(self, group_tree_counts, group_class, status=None):
117
+ def _bulk_group_updater(self, group_tree_counts, group_class, status=None):
112
118
  """Bulk update groups."""
113
119
  count = 0
114
120
  if not group_tree_counts:
@@ -125,33 +131,11 @@ class CreateForeignKeysMixin(QueuedThread):
125
131
  count += len(update_groups)
126
132
  self.log.info(f"Updated {count} {group_class.__name__}s.")
127
133
  if status:
134
+ status.complete = status.complete or 0
128
135
  status.complete += count
129
136
  self.status_controller.update(status)
130
137
  return count
131
138
 
132
- @status_notify(status_type=ImportStatusTypes.DIRS_MODIFIED, updates=False)
133
- def bulk_folders_modified(self, paths, library, **kwargs):
134
- """Update folders stat and nothing else."""
135
- count = 0
136
- if not paths:
137
- return count
138
- folders = Folder.objects.filter(library=library, path__in=paths).only(
139
- "stat", "updated_at"
140
- )
141
- update_folders = []
142
- now = Now()
143
- for folder in folders.iterator():
144
- if Path(folder.path).exists():
145
- folder.set_stat()
146
- folder.updated_at = now
147
- update_folders.append(folder)
148
- Folder.objects.bulk_update(
149
- update_folders, fields=_BULK_UPDATE_FOLDER_MODIFIED_FIELDS
150
- )
151
- count += len(update_folders)
152
- self.log.info(f"Modified {count} folders")
153
- return count
154
-
155
139
  @status_notify()
156
140
  def bulk_folders_create(self, folder_paths, library, status=None):
157
141
  """Create folders breadth first."""
@@ -176,9 +160,10 @@ class CreateForeignKeysMixin(QueuedThread):
176
160
  try:
177
161
  parent = Folder.objects.get(path=parent_path)
178
162
  except Folder.DoesNotExist:
179
- if parent_path != library.path:
163
+ if path.parent != Path(library.path):
180
164
  self.log.exception(
181
- f"Can't find parent folder {parent_path} for {path}"
165
+ f"Can't find parent folder {parent_path}"
166
+ f" for {path} in library {library.path}"
182
167
  )
183
168
  folder = Folder(
184
169
  library=library,
@@ -191,19 +176,20 @@ class CreateForeignKeysMixin(QueuedThread):
191
176
  Folder.objects.bulk_create(
192
177
  create_folders,
193
178
  update_conflicts=True,
194
- update_fields=_BULK_UPDATE_FOLDER_MODIFIED_FIELDS,
179
+ update_fields=BULK_UPDATE_FOLDER_MODIFIED_FIELDS,
195
180
  unique_fields=Folder._meta.unique_together[0], # type: ignore
196
181
  )
197
182
  count += len(create_folders)
198
183
  if status:
199
- status.complete = len(create_folders)
184
+ status.complete = status.complete or 0
185
+ status.complete += len(create_folders)
200
186
  self.status_controller.update(status)
201
187
 
202
188
  self.log.info(f"Created {count} Folders.")
203
189
  return count
204
190
 
205
191
  @status_notify()
206
- def bulk_create_named_models(self, names, named_class, status=None):
192
+ def _bulk_create_named_models(self, names, named_class, status=None):
207
193
  """Bulk create named models."""
208
194
  count = len(names)
209
195
  if not count:
@@ -221,33 +207,146 @@ class CreateForeignKeysMixin(QueuedThread):
221
207
  )
222
208
  self.log.info(f"Created {count} {named_class.__name__}s.")
223
209
  if status:
210
+ status.complete = status.complete or 0
224
211
  status.complete += count
225
212
  self.status_controller.update(status)
226
213
  return count
227
214
 
228
- @status_notify()
229
- def bulk_create_creators(self, create_creator_tuples, status=None):
230
- """Bulk create creators."""
231
- if not create_creator_tuples:
232
- return 0
215
+ @staticmethod
216
+ def _create_creator(role_name, person_name):
217
+ role = CreatorRole.objects.get(name=role_name) if role_name else None
218
+ person = CreatorPerson.objects.get(name=person_name)
219
+ return Creator(role=role, person=person)
233
220
 
234
- create_creators = []
235
- for role_name, person_name in create_creator_tuples:
236
- role = CreatorRole.objects.get(name=role_name) if role_name else None
237
- person = CreatorPerson.objects.get(name=person_name)
238
- creator = Creator(role=role, person=person)
221
+ @staticmethod
222
+ def _create_story_arc_number(name, number):
223
+ story_arc = StoryArc.objects.get(name=name)
224
+ return StoryArcNumber(story_arc=story_arc, number=number)
239
225
 
240
- create_creators.append(creator)
226
+ def _bulk_create_dict_models(
227
+ self, create_tuples, create_method, model, status=None
228
+ ):
229
+ """Bulk create a dict type m2m model."""
230
+ if not create_tuples:
231
+ return 0
241
232
 
242
- Creator.objects.bulk_create(
243
- create_creators,
233
+ create_objs = []
234
+ for key, value in create_tuples:
235
+ obj = create_method(key, value)
236
+ create_objs.append(obj)
237
+
238
+ model.objects.bulk_create(
239
+ create_objs,
244
240
  update_conflicts=True,
245
- update_fields=_CREATOR_UPDATE_FIELDS,
246
- unique_fields=creator._meta.unique_together[0], # type: ignore
241
+ update_fields=_CREATE_DICT_UPDATE_FIELDS[model],
242
+ unique_fields=model._meta.unique_together[0], # type: ignore
247
243
  )
248
- count = len(create_creators)
249
- self.log.info(f"Created {count} creators.")
244
+ count = len(create_objs)
245
+ self.log.info(f"Created {count} {model.__class__.__name__}s.")
250
246
  if status:
247
+ status.complete = status.complete or 0
251
248
  status.complete += count
252
249
  self.status_controller.update(status)
253
250
  return count
251
+
252
+ @status_notify()
253
+ def _bulk_create_creators(self, create_creator_tuples, status=None):
254
+ """Bulk create creators."""
255
+ return self._bulk_create_dict_models(
256
+ create_creator_tuples,
257
+ self._create_creator,
258
+ Creator,
259
+ status,
260
+ )
261
+
262
+ @status_notify()
263
+ def _bulk_create_story_arc_numbers(
264
+ self, create_story_arc_number_tuples, status=None
265
+ ):
266
+ """Bulk create story_arc_numbers."""
267
+ return self._bulk_create_dict_models(
268
+ create_story_arc_number_tuples,
269
+ self._create_story_arc_number,
270
+ StoryArcNumber,
271
+ status,
272
+ )
273
+
274
+ @staticmethod
275
+ def _get_create_fks_totals(create_data):
276
+ (
277
+ create_groups,
278
+ update_groups,
279
+ create_folder_paths,
280
+ create_fks,
281
+ create_creators,
282
+ create_story_arc_numbers,
283
+ ) = create_data
284
+ total_fks = 0
285
+ for data_group in chain(
286
+ create_groups.values(), update_groups.values(), create_fks.values()
287
+ ):
288
+ total_fks += len(data_group)
289
+ total_fks += (
290
+ len(create_folder_paths)
291
+ + len(create_creators)
292
+ + len(create_story_arc_numbers)
293
+ )
294
+ return total_fks
295
+
296
+ def create_all_fks(self, library, create_data):
297
+ """Bulk create all foreign keys."""
298
+ total_fks = self._get_create_fks_totals(create_data)
299
+ status = Status(ImportStatusTypes.CREATE_FKS, 0, total_fks)
300
+ try:
301
+ self.status_controller.start(status)
302
+ (
303
+ create_groups,
304
+ update_groups,
305
+ create_folder_paths,
306
+ create_fks,
307
+ create_creators,
308
+ create_story_arc_numbers,
309
+ ) = create_data
310
+
311
+ for group_class, group_tree_counts in create_groups.items():
312
+ status.complete += self._bulk_group_creator( # type: ignore
313
+ group_tree_counts,
314
+ group_class,
315
+ status=status,
316
+ )
317
+
318
+ for group_class, group_tree_counts in update_groups.items():
319
+ status.complete += self._bulk_group_updater( # type: ignore
320
+ group_tree_counts,
321
+ group_class,
322
+ status=status,
323
+ )
324
+
325
+ status.complete += self.bulk_folders_create( # type: ignore
326
+ sorted(create_folder_paths),
327
+ library,
328
+ status=status,
329
+ )
330
+
331
+ for named_class, names in create_fks.items():
332
+ status.complete += self._bulk_create_named_models(
333
+ names,
334
+ named_class,
335
+ status=status,
336
+ )
337
+
338
+ # This must happen after creator_fks created by create_named_models
339
+ status.complete += self._bulk_create_creators(
340
+ create_creators,
341
+ status=status,
342
+ )
343
+
344
+ # This must happen after story_arc_fks created by create_named_models
345
+ status.complete += self._bulk_create_story_arc_numbers(
346
+ create_story_arc_numbers,
347
+ status=status,
348
+ )
349
+
350
+ finally:
351
+ self.status_controller.finish(status)
352
+ return status.complete
@@ -14,7 +14,7 @@ class DeletedMixin(QueuedThread):
14
14
  self.librarian_queue.put(task)
15
15
 
16
16
  @status_notify(status_type=ImportStatusTypes.DIRS_DELETED, updates=False)
17
- def bulk_folders_deleted(self, delete_folder_paths, library, **kwargs):
17
+ def _bulk_folders_deleted(self, delete_folder_paths, library, **kwargs):
18
18
  """Bulk delete folders."""
19
19
  if not delete_folder_paths:
20
20
  return 0
@@ -36,7 +36,7 @@ class DeletedMixin(QueuedThread):
36
36
  return count
37
37
 
38
38
  @status_notify(status_type=ImportStatusTypes.FILES_DELETED, updates=False)
39
- def bulk_comics_deleted(self, delete_comic_paths, library, **kwargs):
39
+ def _bulk_comics_deleted(self, delete_comic_paths, library, **kwargs):
40
40
  """Bulk delete comics found missing from the filesystem."""
41
41
  if not delete_comic_paths:
42
42
  return 0
@@ -50,3 +50,12 @@ class DeletedMixin(QueuedThread):
50
50
  self.log.info(f"Deleted {count} comics from {library.path}")
51
51
 
52
52
  return count
53
+
54
+ def delete(self, library, task):
55
+ """Delete files and folders."""
56
+ count = self._bulk_folders_deleted(task.dirs_deleted, library)
57
+ task.dirs_deleted = None
58
+
59
+ count += self._bulk_comics_deleted(task.files_deleted, library)
60
+ task.files_deleted = None
61
+ return count
@@ -3,8 +3,9 @@ from pathlib import Path
3
3
 
4
4
  from django.db.models.functions import Now
5
5
 
6
- from codex.librarian.importer.status import status_notify
6
+ from codex.librarian.importer.status import ImportStatusTypes, status_notify
7
7
  from codex.models import Comic, FailedImport
8
+ from codex.status import Status
8
9
  from codex.threads import QueuedThread
9
10
 
10
11
  _BULK_UPDATE_FAILED_IMPORT_FIELDS = ("name", "stat", "updated_at")
@@ -38,7 +39,7 @@ class FailedImportsMixin(QueuedThread):
38
39
  return succeeded_failed_imports | missing_failed_imports
39
40
 
40
41
  @status_notify()
41
- def query_failed_imports(
42
+ def _query_failed_imports(
42
43
  self,
43
44
  failed_imports,
44
45
  library,
@@ -73,7 +74,7 @@ class FailedImportsMixin(QueuedThread):
73
74
  return count
74
75
 
75
76
  @status_notify()
76
- def bulk_update_failed_imports(
77
+ def _bulk_update_failed_imports(
77
78
  self, update_failed_imports, library, **kwargs
78
79
  ) -> int:
79
80
  """Bulk update failed imports."""
@@ -105,7 +106,7 @@ class FailedImportsMixin(QueuedThread):
105
106
  return count
106
107
 
107
108
  @status_notify()
108
- def bulk_create_failed_imports(self, create_failed_imports, library, **kwargs):
109
+ def _bulk_create_failed_imports(self, create_failed_imports, library, **kwargs):
109
110
  """Bulk create failed imports."""
110
111
  if not create_failed_imports:
111
112
  return 0
@@ -130,7 +131,7 @@ class FailedImportsMixin(QueuedThread):
130
131
  return count
131
132
 
132
133
  @status_notify()
133
- def bulk_cleanup_failed_imports(
134
+ def _bulk_cleanup_failed_imports(
134
135
  self, delete_failed_imports_paths, library, **kwargs
135
136
  ):
136
137
  """Remove FailedImport objects that have since succeeded."""
@@ -144,3 +145,38 @@ class FailedImportsMixin(QueuedThread):
144
145
  count = len(delete_failed_imports_paths)
145
146
  self.log.info(f"Cleaned up {count} failed imports from {library.path}")
146
147
  return count
148
+
149
+ def fail_imports(self, library, failed_imports, is_files_deleted):
150
+ """Handle failed imports."""
151
+ created_count = 0
152
+ try:
153
+ fis = {"update_fis": {}, "create_fis": {}, "delete_fi_paths": set()}
154
+ if is_files_deleted:
155
+ # if any files were deleted. Run the failed import check
156
+ failed_imports["files_deleted"] = True
157
+ status = Status(ImportStatusTypes.FAILED_IMPORTS, 0, len(failed_imports))
158
+ status.total = self._query_failed_imports(
159
+ failed_imports,
160
+ library,
161
+ fis,
162
+ status=status,
163
+ )
164
+
165
+ self._bulk_update_failed_imports(
166
+ fis["update_fis"],
167
+ library,
168
+ status=status,
169
+ )
170
+
171
+ created_count = self._bulk_create_failed_imports(
172
+ fis["create_fis"],
173
+ library,
174
+ status=status,
175
+ )
176
+
177
+ self._bulk_cleanup_failed_imports(
178
+ fis["delete_fi_paths"], library, status=status
179
+ )
180
+ except Exception:
181
+ self.log.exception("Processing failed imports")
182
+ return bool(created_count)
@@ -6,9 +6,13 @@ from time import sleep, time
6
6
  from django.core.cache import cache
7
7
  from humanize import naturaldelta
8
8
 
9
- from codex.librarian.importer.db_ops import ApplyDBOpsMixin
9
+ from codex.librarian.importer.aggregate_metadata import AggregateMetadataMixin
10
+ from codex.librarian.importer.deleted import DeletedMixin
11
+ from codex.librarian.importer.failed_imports import FailedImportsMixin
12
+ from codex.librarian.importer.moved import MovedMixin
10
13
  from codex.librarian.importer.status import ImportStatusTypes
11
14
  from codex.librarian.importer.tasks import AdoptOrphanFoldersTask, UpdaterDBDiffTask
15
+ from codex.librarian.importer.update_comics import UpdateComicsMixin
12
16
  from codex.librarian.notifier.tasks import FAILED_IMPORTS_TASK, LIBRARY_CHANGED_TASK
13
17
  from codex.librarian.search.status import SearchIndexStatusTypes
14
18
  from codex.librarian.search.tasks import SearchIndexAbortTask, SearchIndexUpdateTask
@@ -19,7 +23,13 @@ from codex.status import Status
19
23
  _WRITE_WAIT_EXPIRY = 60
20
24
 
21
25
 
22
- class ComicImporterThread(ApplyDBOpsMixin):
26
+ class ComicImporterThread(
27
+ AggregateMetadataMixin,
28
+ DeletedMixin,
29
+ UpdateComicsMixin,
30
+ FailedImportsMixin,
31
+ MovedMixin,
32
+ ):
23
33
  """A worker to handle all bulk database updates."""
24
34
 
25
35
  def _wait_for_filesystem_ops_to_finish(self, task: UpdaterDBDiffTask) -> bool:
@@ -177,6 +187,13 @@ class ComicImporterThread(ApplyDBOpsMixin):
177
187
  self._log_task(library.path, task)
178
188
  self._init_librarian_status(task, library.path)
179
189
 
190
+ def _create_comic_relations(self, library, fks):
191
+ """Query all foreign keys to determine what needs creating, then create them."""
192
+ if not fks:
193
+ return 0
194
+ create_data = self.query_all_missing_fks(library.path, fks)
195
+ return self.create_all_fks(library, create_data)
196
+
180
197
  def _finish_apply_status(self, library):
181
198
  """Finish all librarian statuses."""
182
199
  library.update_in_progress = False
@@ -233,22 +250,28 @@ class ComicImporterThread(ApplyDBOpsMixin):
233
250
  "fks": fks,
234
251
  "fis": fis,
235
252
  }
236
- self.read_metadata(
237
- library.path, modified_paths | created_paths, all_metadata
253
+ self.get_aggregate_metadata(
254
+ modified_paths | created_paths, library.path, all_metadata
238
255
  )
256
+ all_metadata = None
239
257
  modified_paths -= fis.keys()
240
258
  created_paths -= fis.keys()
241
259
 
242
- changed += self.create_comic_relations(library, fks)
260
+ changed += self._create_comic_relations(library, fks)
261
+ fks = None
243
262
 
244
- simple_metadata = {"mds": mds, "m2m_mds": m2m_mds}
245
- imported_count = self.update_create_and_link_comics(
246
- library, modified_paths, created_paths, simple_metadata
263
+ imported_count = self.bulk_update_comics(
264
+ modified_paths,
265
+ library,
266
+ created_paths,
267
+ mds,
247
268
  )
269
+ modified_paths = None
270
+ imported_count += self.bulk_create_comics(created_paths, library, mds)
271
+ created_paths = mds = None
272
+ self.bulk_query_and_link_comic_m2m_fields(m2m_mds)
273
+ m2m_mds = None
248
274
  changed += imported_count
249
- all_metadata = (
250
- simple_metadata
251
- ) = modified_paths = created_paths = mds = m2m_mds = fks = None
252
275
 
253
276
  new_failed_imports = self.fail_imports(
254
277
  library, fis, bool(task.files_deleted)
@@ -11,6 +11,7 @@ from codex.models import (
11
11
  Imprint,
12
12
  Publisher,
13
13
  Series,
14
+ StoryArcNumber,
14
15
  Volume,
15
16
  )
16
17
  from codex.threads import QueuedThread
@@ -50,31 +51,31 @@ class LinkComicsMixin(QueuedThread):
50
51
  md["parent_folder"] = Folder.objects.get(path=parent_path)
51
52
 
52
53
  @staticmethod
53
- def _link_folders(folder_paths):
54
+ def _get_link_folders_filter(folder_paths):
54
55
  """Get the ids of all folders to link."""
55
- if not folder_paths:
56
- return set()
57
- folder_pks = Folder.objects.filter(path__in=folder_paths).values_list(
58
- "pk", flat=True
59
- )
60
- return frozenset(folder_pks)
56
+ return Q(path__in=folder_paths)
61
57
 
62
58
  @staticmethod
63
- def _link_creators(creators_md):
59
+ def _get_link_creators_filter(creators_md):
64
60
  """Get the ids of all creators to link."""
65
- if not creators_md:
66
- return set()
67
61
  creators_filter = Q()
68
62
  for creator in creators_md:
69
- filter_dict = {
70
- "role__name": creator.get("role"),
71
- "person__name": creator["person"],
72
- }
73
- creators_filter |= Q(**filter_dict)
74
- creator_pks = Creator.objects.filter(creators_filter).values_list(
75
- "pk", flat=True
76
- )
77
- return frozenset(creator_pks)
63
+ creators_filter |= Q(
64
+ role__name=creator.get("role"),
65
+ person__name=creator["person"],
66
+ )
67
+ return creators_filter
68
+
69
+ @staticmethod
70
+ def _get_link_story_arc_numbers_filter(story_arc_numbers_md):
71
+ """Get the ids of all story_arc_numbers to link."""
72
+ story_arc_numbers_filter = Q()
73
+ for name, number in story_arc_numbers_md.items():
74
+ story_arc_numbers_filter |= Q(
75
+ story_arc__name=name,
76
+ number=number,
77
+ )
78
+ return story_arc_numbers_filter
78
79
 
79
80
  def _link_named_m2ms(self, all_m2m_links, comic_pk, md):
80
81
  """Set the ids of all named m2m fields into the comic dict."""
@@ -90,6 +91,20 @@ class LinkComicsMixin(QueuedThread):
90
91
  all_m2m_links[field] = {}
91
92
  all_m2m_links[field][comic_pk] = frozenset(pks)
92
93
 
94
+ def _link_prepare_special_m2ms(self, link_data, key, model, get_link_filter_method):
95
+ """Prepare special m2m for linking."""
96
+ (all_m2m_links, md, comic_pk) = link_data
97
+ values = md.pop(key, [])
98
+ if not values:
99
+ return
100
+ if key not in all_m2m_links:
101
+ all_m2m_links[key] = {}
102
+
103
+ m2m_filter = get_link_filter_method(values)
104
+ pks = model.objects.filter(m2m_filter).values_list("pk", flat=True)
105
+ result = frozenset(pks)
106
+ all_m2m_links[key][comic_pk] = result
107
+
93
108
  def _link_comic_m2m_fields(self, m2m_mds):
94
109
  """Get the complete m2m field data to create."""
95
110
  all_m2m_links = {}
@@ -97,17 +112,19 @@ class LinkComicsMixin(QueuedThread):
97
112
  comics = Comic.objects.filter(path__in=comic_paths).values_list("pk", "path")
98
113
  for comic_pk, comic_path in comics:
99
114
  md = m2m_mds[comic_path]
100
- if "folders" not in all_m2m_links:
101
- all_m2m_links["folders"] = {}
102
- try:
103
- folder_paths = md.pop("folders")
104
- except KeyError:
105
- folder_paths = []
106
- all_m2m_links["folders"][comic_pk] = self._link_folders(folder_paths)
107
- if "creators" not in all_m2m_links:
108
- all_m2m_links["creators"] = {}
109
- creators_md = md.pop("creators", None)
110
- all_m2m_links["creators"][comic_pk] = self._link_creators(creators_md)
115
+ link_data = (all_m2m_links, md, comic_pk)
116
+ self._link_prepare_special_m2ms(
117
+ link_data, "folders", Folder, self._get_link_folders_filter
118
+ )
119
+ self._link_prepare_special_m2ms(
120
+ link_data, "creators", Creator, self._get_link_creators_filter
121
+ )
122
+ self._link_prepare_special_m2ms(
123
+ link_data,
124
+ "story_arc_numbers",
125
+ StoryArcNumber,
126
+ self._get_link_story_arc_numbers_filter,
127
+ )
111
128
  self._link_named_m2ms(all_m2m_links, comic_pk, md)
112
129
  return all_m2m_links
113
130
 
@@ -125,6 +142,7 @@ class LinkComicsMixin(QueuedThread):
125
142
  )
126
143
  all_del_pks |= set(del_pks)
127
144
  if status:
145
+ status.total = status.total or 0
128
146
  status.total += len(del_pks)
129
147
  self.status_controller.update(status)
130
148
 
@@ -135,6 +153,7 @@ class LinkComicsMixin(QueuedThread):
135
153
  )
136
154
  missing_pks = set(pks) - extant_pks
137
155
  if status:
156
+ status.total = status.total or 0
138
157
  status.total += len(missing_pks)
139
158
  self.status_controller.update(status)
140
159
  for pk in missing_pks:
@@ -165,6 +184,7 @@ class LinkComicsMixin(QueuedThread):
165
184
  m2m_links, ThroughModel, through_field_id_name, status
166
185
  )
167
186
  if status:
187
+ status.total = status.total or 0
168
188
  status.total += len(tms) + len(all_del_pks)
169
189
  self.status_controller.update(status)
170
190
 
@@ -181,15 +201,18 @@ class LinkComicsMixin(QueuedThread):
181
201
  " relations for altered comics."
182
202
  )
183
203
  if status:
204
+ status.complete = status.complete or 0
184
205
  status.complete += created_count
185
206
  self.status_controller.update(status)
186
207
 
187
208
  if del_count := len(all_del_pks):
188
- ThroughModel.objects.filter(comic_id__in=all_del_pks).delete()
209
+ del_qs = ThroughModel.objects.filter(pk__in=all_del_pks)
210
+ del_qs.delete()
189
211
  self.log.info(
190
212
  f"Deleted {del_count} stale {field_name} relations for altered comics.",
191
213
  )
192
214
  if status:
215
+ status.complete = status.complete or 0
193
216
  status.complete += del_count
194
217
  self.status_controller.update(status)
195
218