library 3.2.4__tar.gz → 3.2.6__tar.gz

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.
Files changed (159) hide show
  1. {library-3.2.4 → library-3.2.6}/.github/README.md +24 -2
  2. {library-3.2.4 → library-3.2.6}/PKG-INFO +25 -3
  3. {library-3.2.4 → library-3.2.6}/library/__main__.py +3 -2
  4. {library-3.2.4 → library-3.2.6}/library/fsdb/disk_usage.py +11 -6
  5. {library-3.2.4 → library-3.2.6}/library/mediafiles/torrents_start.py +77 -13
  6. {library-3.2.4 → library-3.2.6}/library/playback/torrents_info.py +40 -26
  7. {library-3.2.4 → library-3.2.6}/library/usage.py +23 -1
  8. {library-3.2.4 → library-3.2.6}/library/utils/arggroups.py +16 -0
  9. {library-3.2.4 → library-3.2.6}/library/utils/filter_engine.py +2 -2
  10. {library-3.2.4 → library-3.2.6}/library/utils/processes.py +4 -4
  11. {library-3.2.4 → library-3.2.6}/library/utils/web.py +74 -13
  12. {library-3.2.4 → library-3.2.6}/pyproject.toml +1 -1
  13. {library-3.2.4 → library-3.2.6}/LICENSE +0 -0
  14. {library-3.2.4 → library-3.2.6}/library/__init__.py +0 -0
  15. {library-3.2.4 → library-3.2.6}/library/assets/__init__.py +0 -0
  16. {library-3.2.4 → library-3.2.6}/library/assets/calibre.css +0 -0
  17. {library-3.2.4 → library-3.2.6}/library/assets/kotobago.png +0 -0
  18. {library-3.2.4 → library-3.2.6}/library/createdb/__init__.py +0 -0
  19. {library-3.2.4 → library-3.2.6}/library/createdb/av.py +0 -0
  20. {library-3.2.4 → library-3.2.6}/library/createdb/computer_info.py +0 -0
  21. {library-3.2.4 → library-3.2.6}/library/createdb/computers_add.py +0 -0
  22. {library-3.2.4 → library-3.2.6}/library/createdb/fs_add.py +0 -0
  23. {library-3.2.4 → library-3.2.6}/library/createdb/fs_add_metadata.py +0 -0
  24. {library-3.2.4 → library-3.2.6}/library/createdb/gallery_add.py +0 -0
  25. {library-3.2.4 → library-3.2.6}/library/createdb/gallery_backend.py +0 -0
  26. {library-3.2.4 → library-3.2.6}/library/createdb/getty_add.py +0 -0
  27. {library-3.2.4 → library-3.2.6}/library/createdb/hn_add.py +0 -0
  28. {library-3.2.4 → library-3.2.6}/library/createdb/links_add.py +0 -0
  29. {library-3.2.4 → library-3.2.6}/library/createdb/nicotine_import.py +0 -0
  30. {library-3.2.4 → library-3.2.6}/library/createdb/places_import.py +0 -0
  31. {library-3.2.4 → library-3.2.6}/library/createdb/reddit_add.py +0 -0
  32. {library-3.2.4 → library-3.2.6}/library/createdb/row_add.py +0 -0
  33. {library-3.2.4 → library-3.2.6}/library/createdb/site_add.py +0 -0
  34. {library-3.2.4 → library-3.2.6}/library/createdb/substack.py +0 -0
  35. {library-3.2.4 → library-3.2.6}/library/createdb/subtitle.py +0 -0
  36. {library-3.2.4 → library-3.2.6}/library/createdb/tables_add.py +0 -0
  37. {library-3.2.4 → library-3.2.6}/library/createdb/tabs_add.py +0 -0
  38. {library-3.2.4 → library-3.2.6}/library/createdb/tildes.py +0 -0
  39. {library-3.2.4 → library-3.2.6}/library/createdb/torrents_add.py +0 -0
  40. {library-3.2.4 → library-3.2.6}/library/createdb/tube_add.py +0 -0
  41. {library-3.2.4 → library-3.2.6}/library/createdb/tube_backend.py +0 -0
  42. {library-3.2.4 → library-3.2.6}/library/createdb/web_add.py +0 -0
  43. {library-3.2.4 → library-3.2.6}/library/data/__init__.py +0 -0
  44. {library-3.2.4 → library-3.2.6}/library/data/dictionary.pkl +0 -0
  45. {library-3.2.4 → library-3.2.6}/library/data/ffmpeg_errors.py +0 -0
  46. {library-3.2.4 → library-3.2.6}/library/data/http_errors.py +0 -0
  47. {library-3.2.4 → library-3.2.6}/library/data/imagemagick_errors.py +0 -0
  48. {library-3.2.4 → library-3.2.6}/library/data/unar_errors.py +0 -0
  49. {library-3.2.4 → library-3.2.6}/library/data/wordbank.py +0 -0
  50. {library-3.2.4 → library-3.2.6}/library/data/yt_dlp_errors.py +0 -0
  51. {library-3.2.4 → library-3.2.6}/library/editdb/__init__.py +0 -0
  52. {library-3.2.4 → library-3.2.6}/library/editdb/dedupe_db.py +0 -0
  53. {library-3.2.4 → library-3.2.6}/library/editdb/dedupe_media.py +0 -0
  54. {library-3.2.4 → library-3.2.6}/library/editdb/merge_online_local.py +0 -0
  55. {library-3.2.4 → library-3.2.6}/library/editdb/mpv_watchlater.py +0 -0
  56. {library-3.2.4 → library-3.2.6}/library/editdb/pushshift.py +0 -0
  57. {library-3.2.4 → library-3.2.6}/library/editdb/reddit_selftext.py +0 -0
  58. {library-3.2.4 → library-3.2.6}/library/files/__init__.py +0 -0
  59. {library-3.2.4 → library-3.2.6}/library/files/christen.py +0 -0
  60. {library-3.2.4 → library-3.2.6}/library/files/sample_compare.py +0 -0
  61. {library-3.2.4 → library-3.2.6}/library/files/sample_hash.py +0 -0
  62. {library-3.2.4 → library-3.2.6}/library/files/similar_files.py +0 -0
  63. {library-3.2.4 → library-3.2.6}/library/folders/__init__.py +0 -0
  64. {library-3.2.4 → library-3.2.6}/library/folders/big_dirs.py +0 -0
  65. {library-3.2.4 → library-3.2.6}/library/folders/filter_src.py +0 -0
  66. {library-3.2.4 → library-3.2.6}/library/folders/merge_mv.py +0 -0
  67. {library-3.2.4 → library-3.2.6}/library/folders/mergerfs_cp.py +0 -0
  68. {library-3.2.4 → library-3.2.6}/library/folders/mount_stats.py +0 -0
  69. {library-3.2.4 → library-3.2.6}/library/folders/move_list.py +0 -0
  70. {library-3.2.4 → library-3.2.6}/library/folders/scatter.py +0 -0
  71. {library-3.2.4 → library-3.2.6}/library/folders/similar_folders.py +0 -0
  72. {library-3.2.4 → library-3.2.6}/library/fsdb/__init__.py +0 -0
  73. {library-3.2.4 → library-3.2.6}/library/fsdb/filesystem.py +0 -0
  74. {library-3.2.4 → library-3.2.6}/library/fsdb/folder_stats.py +0 -0
  75. {library-3.2.4 → library-3.2.6}/library/fsdb/search_db.py +0 -0
  76. {library-3.2.4 → library-3.2.6}/library/lb.py +0 -0
  77. {library-3.2.4 → library-3.2.6}/library/mediadb/__init__.py +0 -0
  78. {library-3.2.4 → library-3.2.6}/library/mediadb/block.py +0 -0
  79. {library-3.2.4 → library-3.2.6}/library/mediadb/db_history.py +0 -0
  80. {library-3.2.4 → library-3.2.6}/library/mediadb/db_media.py +0 -0
  81. {library-3.2.4 → library-3.2.6}/library/mediadb/db_playlists.py +0 -0
  82. {library-3.2.4 → library-3.2.6}/library/mediadb/download.py +0 -0
  83. {library-3.2.4 → library-3.2.6}/library/mediadb/download_status.py +0 -0
  84. {library-3.2.4 → library-3.2.6}/library/mediadb/history.py +0 -0
  85. {library-3.2.4 → library-3.2.6}/library/mediadb/history_add.py +0 -0
  86. {library-3.2.4 → library-3.2.6}/library/mediadb/optimize_db.py +0 -0
  87. {library-3.2.4 → library-3.2.6}/library/mediadb/playlists.py +0 -0
  88. {library-3.2.4 → library-3.2.6}/library/mediadb/redownload.py +0 -0
  89. {library-3.2.4 → library-3.2.6}/library/mediadb/search.py +0 -0
  90. {library-3.2.4 → library-3.2.6}/library/mediadb/stats.py +0 -0
  91. {library-3.2.4 → library-3.2.6}/library/mediafiles/__init__.py +0 -0
  92. {library-3.2.4 → library-3.2.6}/library/mediafiles/images_to_pdf.py +0 -0
  93. {library-3.2.4 → library-3.2.6}/library/mediafiles/media_check.py +0 -0
  94. {library-3.2.4 → library-3.2.6}/library/mediafiles/pdf_edit.py +0 -0
  95. {library-3.2.4 → library-3.2.6}/library/mediafiles/process_ffmpeg.py +0 -0
  96. {library-3.2.4 → library-3.2.6}/library/mediafiles/process_image.py +0 -0
  97. {library-3.2.4 → library-3.2.6}/library/mediafiles/process_media.py +0 -0
  98. {library-3.2.4 → library-3.2.6}/library/mediafiles/process_text.py +0 -0
  99. {library-3.2.4 → library-3.2.6}/library/mediafiles/torrents_dump.py +0 -0
  100. {library-3.2.4 → library-3.2.6}/library/mediafiles/unardel.py +0 -0
  101. {library-3.2.4 → library-3.2.6}/library/misc/__init__.py +0 -0
  102. {library-3.2.4 → library-3.2.6}/library/misc/dedupe_czkawka.py +0 -0
  103. {library-3.2.4 → library-3.2.6}/library/misc/export_text.py +0 -0
  104. {library-3.2.4 → library-3.2.6}/library/misc/search_help.py +0 -0
  105. {library-3.2.4 → library-3.2.6}/library/multidb/__init__.py +0 -0
  106. {library-3.2.4 → library-3.2.6}/library/multidb/allocate_torrents.py +0 -0
  107. {library-3.2.4 → library-3.2.6}/library/multidb/copy_play_counts.py +0 -0
  108. {library-3.2.4 → library-3.2.6}/library/multidb/merge_dbs.py +0 -0
  109. {library-3.2.4 → library-3.2.6}/library/playback/__init__.py +0 -0
  110. {library-3.2.4 → library-3.2.6}/library/playback/links_open.py +0 -0
  111. {library-3.2.4 → library-3.2.6}/library/playback/media_player.py +0 -0
  112. {library-3.2.4 → library-3.2.6}/library/playback/media_printer.py +0 -0
  113. {library-3.2.4 → library-3.2.6}/library/playback/play_actions.py +0 -0
  114. {library-3.2.4 → library-3.2.6}/library/playback/playback_control.py +0 -0
  115. {library-3.2.4 → library-3.2.6}/library/playback/post_actions.py +0 -0
  116. {library-3.2.4 → library-3.2.6}/library/playback/surf.py +0 -0
  117. {library-3.2.4 → library-3.2.6}/library/playback/tabs_open.py +0 -0
  118. {library-3.2.4 → library-3.2.6}/library/playback/torrents_remaining.py +0 -0
  119. {library-3.2.4 → library-3.2.6}/library/readme.py +0 -0
  120. {library-3.2.4 → library-3.2.6}/library/tablefiles/__init__.py +0 -0
  121. {library-3.2.4 → library-3.2.6}/library/tablefiles/columns.py +0 -0
  122. {library-3.2.4 → library-3.2.6}/library/tablefiles/eda.py +0 -0
  123. {library-3.2.4 → library-3.2.6}/library/tablefiles/incremental_diff.py +0 -0
  124. {library-3.2.4 → library-3.2.6}/library/tablefiles/markdown_tables.py +0 -0
  125. {library-3.2.4 → library-3.2.6}/library/tablefiles/mcda.py +0 -0
  126. {library-3.2.4 → library-3.2.6}/library/tablefiles/plot.py +0 -0
  127. {library-3.2.4 → library-3.2.6}/library/text/__init__.py +0 -0
  128. {library-3.2.4 → library-3.2.6}/library/text/cluster_sort.py +0 -0
  129. {library-3.2.4 → library-3.2.6}/library/text/combinations.py +0 -0
  130. {library-3.2.4 → library-3.2.6}/library/text/expand_links.py +0 -0
  131. {library-3.2.4 → library-3.2.6}/library/text/extract_links.py +0 -0
  132. {library-3.2.4 → library-3.2.6}/library/text/extract_text.py +0 -0
  133. {library-3.2.4 → library-3.2.6}/library/text/json_keys_rename.py +0 -0
  134. {library-3.2.4 → library-3.2.6}/library/text/markdown_links.py +0 -0
  135. {library-3.2.4 → library-3.2.6}/library/text/nouns.py +0 -0
  136. {library-3.2.4 → library-3.2.6}/library/text/regex_sort.py +0 -0
  137. {library-3.2.4 → library-3.2.6}/library/text/timestamps.py +0 -0
  138. {library-3.2.4 → library-3.2.6}/library/utils/__init__.py +0 -0
  139. {library-3.2.4 → library-3.2.6}/library/utils/arg_utils.py +0 -0
  140. {library-3.2.4 → library-3.2.6}/library/utils/argparse_utils.py +0 -0
  141. {library-3.2.4 → library-3.2.6}/library/utils/consts.py +0 -0
  142. {library-3.2.4 → library-3.2.6}/library/utils/date_utils.py +0 -0
  143. {library-3.2.4 → library-3.2.6}/library/utils/db_utils.py +0 -0
  144. {library-3.2.4 → library-3.2.6}/library/utils/devices.py +0 -0
  145. {library-3.2.4 → library-3.2.6}/library/utils/file_utils.py +0 -0
  146. {library-3.2.4 → library-3.2.6}/library/utils/gui.py +0 -0
  147. {library-3.2.4 → library-3.2.6}/library/utils/iterables.py +0 -0
  148. {library-3.2.4 → library-3.2.6}/library/utils/log_utils.py +0 -0
  149. {library-3.2.4 → library-3.2.6}/library/utils/mpv_utils.py +0 -0
  150. {library-3.2.4 → library-3.2.6}/library/utils/nums.py +0 -0
  151. {library-3.2.4 → library-3.2.6}/library/utils/objects.py +0 -0
  152. {library-3.2.4 → library-3.2.6}/library/utils/path_utils.py +0 -0
  153. {library-3.2.4 → library-3.2.6}/library/utils/pd_utils.py +0 -0
  154. {library-3.2.4 → library-3.2.6}/library/utils/printing.py +0 -0
  155. {library-3.2.4 → library-3.2.6}/library/utils/remote_processes.py +0 -0
  156. {library-3.2.4 → library-3.2.6}/library/utils/shell_utils.py +0 -0
  157. {library-3.2.4 → library-3.2.6}/library/utils/sql_utils.py +0 -0
  158. {library-3.2.4 → library-3.2.6}/library/utils/sqlgroups.py +0 -0
  159. {library-3.2.4 → library-3.2.6}/library/utils/strings.py +0 -0
@@ -99,7 +99,7 @@ To stop playing press Ctrl+C in either the terminal or mpv
99
99
  <details><summary>List all subcommands</summary>
100
100
 
101
101
  $ library
102
- library (v3.2.004; 102 subcommands)
102
+ library (v3.2.006; 102 subcommands)
103
103
 
104
104
  Create database subcommands:
105
105
  ╭─────────────────┬──────────────────────────────────────────╮
@@ -2190,10 +2190,20 @@ Inspired somewhat by https://nikkhokkho.sourceforge.io/?page=FileOptimizer
2190
2190
  <details><summary>Start torrents (qBittorrent-nox)</summary>
2191
2191
 
2192
2192
  $ library torrents-start -h
2193
- usage: library torrents-start [--prefix /mnt/d/] PATH ...
2193
+ usage: library torrents-start [--prefix /mnt/d/] [--scan] PATH ...
2194
2194
 
2195
2195
  Start torrent files in qBittorrent-nox
2196
2196
 
2197
+ By default the temp/download paths are derived from the configured drives and
2198
+ prefixes (and optional tracker subdirectories). With --scan, the temp and download
2199
+ drive prefixes are searched to locate each torrent's files on disk. If
2200
+ --tracker-dirnames is also set, the tracker subdirectories of each prefix are
2201
+ searched as well. Files found under the temp/downloading prefix are used as the
2202
+ download_path (temp), and files under the download/seeding prefix as the save_path,
2203
+ so qBittorrent hash-scans and moves any incomplete data into place. Torrents with
2204
+ no file matches are logged, skipped, and their .torrent files are left in place.
2205
+ --scan overrides the --tracker-dirnames layout when choosing save paths.
2206
+
2197
2207
 
2198
2208
  </details>
2199
2209
 
@@ -3032,6 +3042,18 @@ Inspired somewhat by https://nikkhokkho.sourceforge.io/?page=FileOptimizer
3032
3042
 
3033
3043
  library torrents query
3034
3044
 
3045
+ Positional torrent searches use the path aligned with torrent status by default.
3046
+ Search a specific path field or both fields with --path-search:
3047
+
3048
+ library torrents --dl --path-search save /mnt/d4/
3049
+ library torrents --dl --path-search both /mnt/d4/
3050
+
3051
+ Find incomplete torrents whose temp and final paths use different drives.
3052
+ --different-drives searches both path fields unless --path-search is specified:
3053
+
3054
+ library torrents --dl --different-drives
3055
+ library torrents --dl --different-drives /mnt/d4/
3056
+
3035
3057
  Aggregate torrent info (torrents-status)
3036
3058
 
3037
3059
  library torrents -pa
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: library
3
- Version: 3.2.4
3
+ Version: 3.2.6
4
4
  Summary: xk media library
5
5
  Author-Email: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com>
6
6
  License: BSD 3-Clause License
@@ -207,7 +207,7 @@ To stop playing press Ctrl+C in either the terminal or mpv
207
207
  <details><summary>List all subcommands</summary>
208
208
 
209
209
  $ library
210
- library (v3.2.004; 102 subcommands)
210
+ library (v3.2.006; 102 subcommands)
211
211
 
212
212
  Create database subcommands:
213
213
  ╭─────────────────┬──────────────────────────────────────────╮
@@ -2298,10 +2298,20 @@ Inspired somewhat by https://nikkhokkho.sourceforge.io/?page=FileOptimizer
2298
2298
  <details><summary>Start torrents (qBittorrent-nox)</summary>
2299
2299
 
2300
2300
  $ library torrents-start -h
2301
- usage: library torrents-start [--prefix /mnt/d/] PATH ...
2301
+ usage: library torrents-start [--prefix /mnt/d/] [--scan] PATH ...
2302
2302
 
2303
2303
  Start torrent files in qBittorrent-nox
2304
2304
 
2305
+ By default the temp/download paths are derived from the configured drives and
2306
+ prefixes (and optional tracker subdirectories). With --scan, the temp and download
2307
+ drive prefixes are searched to locate each torrent's files on disk. If
2308
+ --tracker-dirnames is also set, the tracker subdirectories of each prefix are
2309
+ searched as well. Files found under the temp/downloading prefix are used as the
2310
+ download_path (temp), and files under the download/seeding prefix as the save_path,
2311
+ so qBittorrent hash-scans and moves any incomplete data into place. Torrents with
2312
+ no file matches are logged, skipped, and their .torrent files are left in place.
2313
+ --scan overrides the --tracker-dirnames layout when choosing save paths.
2314
+
2305
2315
 
2306
2316
  </details>
2307
2317
 
@@ -3140,6 +3150,18 @@ Inspired somewhat by https://nikkhokkho.sourceforge.io/?page=FileOptimizer
3140
3150
 
3141
3151
  library torrents query
3142
3152
 
3153
+ Positional torrent searches use the path aligned with torrent status by default.
3154
+ Search a specific path field or both fields with --path-search:
3155
+
3156
+ library torrents --dl --path-search save /mnt/d4/
3157
+ library torrents --dl --path-search both /mnt/d4/
3158
+
3159
+ Find incomplete torrents whose temp and final paths use different drives.
3160
+ --different-drives searches both path fields unless --path-search is specified:
3161
+
3162
+ library torrents --dl --different-drives
3163
+ library torrents --dl --different-drives /mnt/d4/
3164
+
3143
3165
  Aggregate torrent info (torrents-status)
3144
3166
 
3145
3167
  library torrents -pa
@@ -5,7 +5,7 @@ from tabulate import tabulate
5
5
  from library.utils import argparse_utils, iterables
6
6
  from library.utils.log_utils import log
7
7
 
8
- __version__ = "3.2.004"
8
+ __version__ = "3.2.006"
9
9
 
10
10
  progs = {
11
11
  "Create database subcommands": {
@@ -367,12 +367,13 @@ def library(args=None) -> None:
367
367
  log.debug(sys.argv)
368
368
 
369
369
  if hasattr(args, "func"):
370
+ subcommand = sys.argv[1] if len(sys.argv) >= 2 else ""
370
371
  if len(sys.argv) >= 2:
371
372
  del sys.argv[1]
372
373
  try:
373
374
  return args.func()
374
375
  except Exception:
375
- print("Bug found in", " ".join(sys.argv), file=sys.stderr)
376
+ print("Bug found in", subcommand, " ".join(sys.argv), file=sys.stderr)
376
377
  raise
377
378
  else:
378
379
  try:
@@ -50,11 +50,12 @@ def parse_args(defaults_override=None):
50
50
  return args
51
51
 
52
52
 
53
- def sort_by(args):
54
- if args.sort_groups_by:
55
- key = args.sort_groups_by.replace(" desc", "")
56
- if key != "priority":
57
- return lambda x: (x.get(key) or 0, objects.Reverser(x.get("path")))
53
+ def sort_by(args, default_sort="priority"):
54
+ sort_groups_by = args.sort_groups_by or default_sort
55
+ if sort_groups_by:
56
+ keys = [key.replace(" desc", "") for key in sort_groups_by.split(",")]
57
+ if keys != ["priority"]:
58
+ return lambda x: (*[x.get(key) or 0 for key in keys], objects.Reverser(x.get("path")))
58
59
 
59
60
  # priority sort
60
61
  return lambda x: (
@@ -170,7 +171,11 @@ def get_subset_group_by_extensions(args) -> list[dict]:
170
171
  if args.sort_groups_by and " desc" in args.sort_groups_by:
171
172
  reverse = False
172
173
 
173
- return sorted([{"path": k, **v} for k, v in d.items()], key=sort_by(args), reverse=reverse)
174
+ return sorted(
175
+ [{"path": k, **v} for k, v in d.items()],
176
+ key=sort_by(args, default_sort="size,count"),
177
+ reverse=reverse,
178
+ )
174
179
 
175
180
 
176
181
  def get_subset_group_by_mimetypes(args) -> list[dict]:
@@ -1,4 +1,4 @@
1
- import getpass, shutil, time
1
+ import getpass, os, shutil, time
2
2
  from pathlib import Path
3
3
  from time import sleep
4
4
 
@@ -102,28 +102,92 @@ def start_qBittorrent(args):
102
102
  return qbt_client
103
103
 
104
104
 
105
+ def scan_torrent_location(torrent, roots, max_depth=3):
106
+ if torrent.num_files() == 0:
107
+ return None, None
108
+ first_file = Path(next(iter(torrent.files())).path)
109
+ for root in roots:
110
+ root = Path(root)
111
+ if not root.is_dir():
112
+ continue
113
+ for dirpath, dirnames, _filenames in os.walk(root):
114
+ depth = len(Path(dirpath).relative_to(root).parts)
115
+ if depth > max_depth:
116
+ dirnames[:] = []
117
+ continue
118
+ if (Path(dirpath) / first_file).exists():
119
+ return root, Path(dirpath)
120
+ return None, None
121
+
122
+
123
+ def torrent_paths(args, torrent, temp_prefix, download_prefix):
124
+ if args.scan:
125
+ roots = [temp_prefix, download_prefix]
126
+ if args.tracker_dirnames:
127
+ tracker = get_tracker_domain(torrent)
128
+ if tracker:
129
+ roots += [temp_prefix / tracker, download_prefix / tracker]
130
+ root, location = scan_torrent_location(torrent, roots)
131
+ if not location:
132
+ return None, None
133
+ log.debug("Found files for %s at %s (root %s)", torrent.name(), location, root)
134
+
135
+ if root == temp_prefix or temp_prefix in root.parents:
136
+ # files live under the temp/downloading prefix: keep them as the temp path,
137
+ # and let the download prefix be the final save path
138
+ return download_prefix, location
139
+ # files live under the download/seeding prefix: use them as the final save path,
140
+ # and the temp prefix as where any incomplete data would go
141
+ return location, temp_prefix
142
+
143
+ download_path = download_prefix
144
+ temp_path = temp_prefix
145
+ if args.tracker_dirnames:
146
+ tracker = get_tracker_domain(torrent)
147
+ if tracker:
148
+ download_path /= tracker
149
+ temp_path /= tracker
150
+ return download_path, temp_path
151
+
152
+
105
153
  def torrents_start():
106
154
  args = parse_args()
107
155
 
108
156
  qbt_client = start_qBittorrent(args)
109
157
 
110
158
  if args.temp_drive and Path(args.temp_drive).is_absolute():
111
- temp_prefix = Path(args.temp_drive)
159
+ temp_drive = Path(args.temp_drive)
112
160
  else:
113
- temp_prefix = Path(args.download_drive)
114
- temp_prefix /= args.temp_prefix
115
- download_prefix = Path(args.download_drive) / args.download_prefix
161
+ temp_drive = Path(args.download_drive)
116
162
 
117
- for path in args.paths:
163
+ if "download_drive" in args.defaults and args.temp_drive:
164
+ # --temp-drive set but not --download-drive: use the same drive for both prefixes
165
+ download_drive = temp_drive
166
+ else:
167
+ download_drive = Path(args.download_drive)
168
+ temp_prefix = temp_drive / args.temp_prefix
169
+ download_prefix = download_drive / args.download_prefix
170
+
171
+ paths = []
172
+ for p in args.paths:
173
+ p = Path(p)
174
+ if p.is_dir():
175
+ paths.extend(p.glob("*.torrent"))
176
+ else:
177
+ paths.append(p)
178
+
179
+ for path in paths:
118
180
  torrent = torrent_decode(path)
119
181
 
120
- download_path = download_prefix
121
- temp_path = temp_prefix
122
- if args.tracker_dirnames:
123
- tracker = get_tracker_domain(torrent)
124
- if tracker:
125
- download_path /= tracker
126
- temp_path /= tracker
182
+ download_path, temp_path = torrent_paths(args, torrent, temp_prefix, download_prefix)
183
+ if download_path is None:
184
+ log.warning(
185
+ "Skipping %s: no file matches under %s or %s (torrent file left in place)",
186
+ path,
187
+ temp_prefix,
188
+ download_prefix,
189
+ )
190
+ continue
127
191
 
128
192
  qbt_client.torrents_add(
129
193
  torrent_files=path,
@@ -297,6 +297,8 @@ def filter_torrents_by_criteria(args, torrents):
297
297
 
298
298
  if args.private is not None:
299
299
  torrents = [t for t in torrents if args.private is t.private]
300
+ if args.different_drives:
301
+ torrents = [t for t in torrents if torrent_paths_on_different_drives(t)]
300
302
  if args.no_tagged:
301
303
  tags = set(args.no_tagged)
302
304
  torrents = [t for t in torrents if tags.isdisjoint(t.tags.split(", "))]
@@ -309,7 +311,7 @@ def filter_torrents_by_criteria(args, torrents):
309
311
  for t in torrents
310
312
  if strings.glob_match_all(
311
313
  args.torrent_search,
312
- [t.name, t.comment, t.save_path if t.state_enum.is_complete else t.download_path, t.hash],
314
+ [t.name, t.comment, *torrent_paths_for_search(args, t), t.hash],
313
315
  )
314
316
  ]
315
317
  if args.torrent_exclude:
@@ -318,7 +320,7 @@ def filter_torrents_by_criteria(args, torrents):
318
320
  for t in torrents
319
321
  if not strings.glob_match_any(
320
322
  args.torrent_exclude,
321
- [t.name, t.comment, t.save_path if t.state_enum.is_complete else t.download_path, t.hash],
323
+ [t.name, t.comment, *torrent_paths_for_search(args, t), t.hash],
322
324
  )
323
325
  ]
324
326
  if args.torrent_include:
@@ -327,7 +329,7 @@ def filter_torrents_by_criteria(args, torrents):
327
329
  for t in torrents
328
330
  if strings.glob_match_any(
329
331
  args.torrent_include,
330
- [t.name, t.comment, t.save_path if t.state_enum.is_complete else t.download_path, t.hash],
332
+ [t.name, t.comment, *torrent_paths_for_search(args, t), t.hash],
331
333
  )
332
334
  ]
333
335
  if args.file_search:
@@ -502,6 +504,36 @@ def map_value_status(t, status):
502
504
  return MAP_VALUE.get(status, t.state == status)
503
505
 
504
506
 
507
+ def torrent_paths_for_search(args, t):
508
+ path_search = getattr(args, "path_search", None)
509
+ if path_search is None:
510
+ path_search = "both" if getattr(args, "different_drives", False) else "status"
511
+ if path_search == "download":
512
+ return [t.download_path]
513
+ if path_search == "save":
514
+ return [t.save_path]
515
+ if path_search == "both":
516
+ return [t.download_path, t.save_path]
517
+ return [t.save_path if t.state_enum.is_complete else t.download_path]
518
+
519
+
520
+ def torrent_paths_on_different_drives(t):
521
+ if not t.download_path or not t.save_path:
522
+ return False
523
+ return path_utils.mountpoint(t.download_path) != path_utils.mountpoint(t.save_path)
524
+
525
+
526
+ def set_torrent_paths(qbt_client, t, temp_path, download_path):
527
+ save_path = download_path if download_path is not None else t.save_path
528
+ if save_path:
529
+ print(" ", t.save_path, "==>", save_path)
530
+ qbt_client.torrents_set_location(str(save_path), torrent_hashes=[t.hash])
531
+
532
+ if temp_path is not None:
533
+ print(" ", t.download_path, "-->", temp_path)
534
+ qbt_client.torrents_set_download_path(str(temp_path), torrent_hashes=[t.hash])
535
+
536
+
505
537
  def torrents_info():
506
538
  args = parse_args()
507
539
 
@@ -914,21 +946,6 @@ def torrents_info():
914
946
  k not in args.defaults for k in ["temp_drive", "temp_prefix", "download_drive", "download_prefix"]
915
947
  )
916
948
  if args.move or alt_move_syntax:
917
-
918
- def set_temp_path(t, temp_path):
919
- if temp_path is None:
920
- return
921
-
922
- print(" ", t.download_path, "-->", temp_path)
923
- qbt_client.torrents_set_download_path(str(temp_path), torrent_hashes=[t.hash])
924
-
925
- def set_download_path(t, download_path):
926
- if download_path is None:
927
- return
928
-
929
- print(" ", t.save_path, "==>", download_path)
930
- qbt_client.torrents_set_save_path(str(download_path), torrent_hashes=[t.hash])
931
-
932
949
  for idx, t in enumerate(torrents):
933
950
  print("Moving", idx + 1, "of", len(torrents))
934
951
 
@@ -993,28 +1010,25 @@ def torrents_info():
993
1010
  log.debug("download_path %s", download_path)
994
1011
 
995
1012
  new_path = download_path if t.state_enum.is_complete else temp_path
1013
+ if new_path is None:
1014
+ new_path = t.save_path if t.state_enum.is_complete else t.download_path
996
1015
  if args.simulate:
997
1016
  print("Moving", t.content_path, "to", new_path)
998
1017
  continue
999
1018
 
1000
1019
  content_path = Path(t.content_path) if t.content_path else None
1001
- if content_path and content_path.exists():
1020
+ if content_path and content_path.exists() and new_path is not None:
1002
1021
  if content_path.is_file():
1003
1022
  print("Moving file", content_path, "to", new_path)
1004
1023
  merge_mv.move(args, [str(content_path)], str(new_path))
1005
1024
  else:
1006
- new_path /= content_path.name
1025
+ new_path = Path(new_path) / content_path.name
1007
1026
  print("Moving folder", content_path, "to", new_path)
1008
1027
  merge_mv.move(args, [str(content_path)], str(new_path))
1009
1028
 
1010
1029
  if not (args.delete_files or args.delete_rows):
1011
1030
  # update metadata
1012
- if t.state_enum.is_complete: # temp path first
1013
- set_temp_path(t, temp_path)
1014
- set_download_path(t, download_path)
1015
- else: # download path first
1016
- set_download_path(t, download_path)
1017
- set_temp_path(t, temp_path)
1031
+ set_torrent_paths(qbt_client, t, temp_path, download_path)
1018
1032
 
1019
1033
  if not originally_stopped:
1020
1034
  qbt_client.torrents_start(torrent_hashes=[t.hash])
@@ -1961,9 +1961,19 @@ torrents_add = """library torrents-add DATABASE PATH ...
1961
1961
  """
1962
1962
 
1963
1963
 
1964
- torrents_start = """library torrents-start [--prefix /mnt/d/] PATH ...
1964
+ torrents_start = """library torrents-start [--prefix /mnt/d/] [--scan] PATH ...
1965
1965
 
1966
1966
  Start torrent files in qBittorrent-nox
1967
+
1968
+ By default the temp/download paths are derived from the configured drives and
1969
+ prefixes (and optional tracker subdirectories). With --scan, the temp and download
1970
+ drive prefixes are searched to locate each torrent's files on disk. If
1971
+ --tracker-dirnames is also set, the tracker subdirectories of each prefix are
1972
+ searched as well. Files found under the temp/downloading prefix are used as the
1973
+ download_path (temp), and files under the download/seeding prefix as the save_path,
1974
+ so qBittorrent hash-scans and moves any incomplete data into place. Torrents with
1975
+ no file matches are logged, skipped, and their .torrent files are left in place.
1976
+ --scan overrides the --tracker-dirnames layout when choosing save paths.
1967
1977
  """
1968
1978
 
1969
1979
  torrents_dump = """library torrents-dump PATH ...
@@ -1981,6 +1991,18 @@ torrents_info = """library torrents-info
1981
1991
 
1982
1992
  library torrents query
1983
1993
 
1994
+ Positional torrent searches use the path aligned with torrent status by default.
1995
+ Search a specific path field or both fields with --path-search:
1996
+
1997
+ library torrents --dl --path-search save /mnt/d4/
1998
+ library torrents --dl --path-search both /mnt/d4/
1999
+
2000
+ Find incomplete torrents whose temp and final paths use different drives.
2001
+ --different-drives searches both path fields unless --path-search is specified:
2002
+
2003
+ library torrents --dl --different-drives
2004
+ library torrents --dl --different-drives /mnt/d4/
2005
+
1984
2006
  Aggregate torrent info (torrents-status)
1985
2007
 
1986
2008
  library torrents -pa
@@ -2717,6 +2717,12 @@ def torrents_start(parser):
2717
2717
  parser.add_argument(
2718
2718
  "--delete-torrent", action=argparse.BooleanOptionalAction, default=True, help="Delete torrent file after adding"
2719
2719
  )
2720
+ parser.add_argument(
2721
+ "--scan",
2722
+ action="store_true",
2723
+ help="Scan the temp/download drives (and their prefixes) to find where each torrent's files are, "
2724
+ "and set both the temp and download paths to that location (overrides --tracker-dirnames)",
2725
+ )
2720
2726
 
2721
2727
 
2722
2728
  def qBittorrent_torrents(parent_parser):
@@ -2789,6 +2795,16 @@ def qBittorrent_torrents(parent_parser):
2789
2795
  help="Include torrents with files currently in use by processes",
2790
2796
  )
2791
2797
  parser.add_argument("--private", action=argparse.BooleanOptionalAction, help="Match only private torrents")
2798
+ parser.add_argument(
2799
+ "--different-drives",
2800
+ action=argparse.BooleanOptionalAction,
2801
+ help="Include torrents whose incomplete and final paths are on different mountpoints",
2802
+ )
2803
+ parser.add_argument(
2804
+ "--path-search",
2805
+ choices=["status", "download", "save", "both"],
2806
+ help="Path used by positional torrent searches: status, download, save, or both",
2807
+ )
2792
2808
 
2793
2809
  parser.add_argument(
2794
2810
  "--sizes",
@@ -427,10 +427,10 @@ def filter_items_by_criteria(args, items):
427
427
 
428
428
  if getattr(args, "time_created", []):
429
429
  items = [d if "time_created" in d else file_utils.get_file_stats(d) for d in items]
430
- items = [d for d in items if d["time_created"] > 0 and args.time_created(d["time_created"])] # type: ignore
430
+ items = [d for d in items if d.get("time_created", 0) > 0 and args.time_created(d["time_created"])] # type: ignore
431
431
  if getattr(args, "time_modified", []):
432
432
  items = [d if "time_modified" in d else file_utils.get_file_stats(d) for d in items]
433
- items = [d for d in items if d["time_modified"] > 0 and args.time_modified(d["time_modified"])] # type: ignore
433
+ items = [d for d in items if d.get("time_modified", 0) > 0 and args.time_modified(d["time_modified"])] # type: ignore
434
434
 
435
435
  if getattr(args, "to_json", False):
436
436
  items = [d if "size" in d else file_utils.get_file_stats(d) for d in items]
@@ -246,16 +246,16 @@ def cmd_detach(*command, **kwargs) -> subprocess.CompletedProcess:
246
246
 
247
247
 
248
248
  def cmd_interactive(*command, strict=True) -> subprocess.CompletedProcess:
249
- return_code = os.spawnvpe(os.P_WAIT, command[0], command, os.environ)
249
+ result = subprocess.run(command, check=False)
250
250
 
251
- if return_code != 0:
252
- msg = f"[{shlex.join(command)}] exited {return_code}"
251
+ if result.returncode != 0:
252
+ msg = f"[{shlex.join(command)}] exited {result.returncode}"
253
253
  if strict:
254
254
  raise RuntimeError(msg)
255
255
  else:
256
256
  log.info(msg)
257
257
 
258
- return subprocess.CompletedProcess(command, return_code)
258
+ return result
259
259
 
260
260
 
261
261
  def Pclose(process) -> subprocess.CompletedProcess: # noqa: N802
@@ -1,4 +1,4 @@
1
- import argparse, datetime, functools, http.cookiejar, os, pathlib, random, re, socket, tempfile, time, urllib.error, urllib.parse, urllib.request, warnings
1
+ import argparse, datetime, functools, http.cookiejar, os, pathlib, random, re, shutil, socket, tempfile, time, urllib.error, urllib.parse, urllib.request, warnings
2
2
  from contextlib import suppress
3
3
  from email.message import Message
4
4
  from pathlib import Path
@@ -322,6 +322,35 @@ def _firefox_default_profile(root):
322
322
  return next((p for p in (profiles.get(s, "Path", fallback=None) for s in sections) if p), None)
323
323
 
324
324
 
325
+ def copy_firefox_profile_for_selenium(source_profile, selenium_profile):
326
+ selenium_profile.mkdir(parents=True, exist_ok=True)
327
+ source_cookies = source_profile / "cookies.sqlite"
328
+ if source_cookies.exists():
329
+ for suffix in ("", "-wal", "-shm"):
330
+ src_file = source_cookies.with_suffix(source_cookies.suffix + suffix)
331
+ if src_file.exists():
332
+ shutil.copy2(src_file, selenium_profile / src_file.name)
333
+ for name in ("logins.json", "key4.db", "cert9.db", "permissions.sqlite"):
334
+ src_file = source_profile / name
335
+ if src_file.exists():
336
+ shutil.copy2(src_file, selenium_profile / name)
337
+
338
+
339
+ SKIP_CHROMIUM_DIRS = {"Cache", "Code Cache", "GPUCache", "Service Worker", "Service Worker/CacheStorage", "Session Storage", "Sessions", "databases", "blob_storage", "File System", "IndexedDB", "WebStorage", "GamepadMappings", "safe_browsing", "SafetyTips", "Subresource Filter", "BudgetDatabase", "CELPuzzles", "Crowd Deny", "MEIPreload", "Segmentation Platform", "TranslateRanker", "TranslateScripts", "VideoDecodeStats"}
340
+
341
+
342
+ def copy_chromium_profile_for_selenium(source_profile, selenium_profile):
343
+ source_profile = Path(source_profile)
344
+ selenium_profile = Path(selenium_profile)
345
+ for item in source_profile.iterdir():
346
+ if item.name in SKIP_CHROMIUM_DIRS or item.name in {"LOCK", "LOG", "LOG.old"} or item.name.startswith(".org.chromium."):
347
+ continue
348
+ if item.is_dir():
349
+ shutil.copytree(item, selenium_profile / item.name, ignore=shutil.ignore_patterns("*.lock", "LOCK"))
350
+ else:
351
+ shutil.copy2(item, selenium_profile / item.name)
352
+
353
+
325
354
  def load_selenium(args, wire=False):
326
355
  if getattr(args, "driver", False):
327
356
  return
@@ -334,6 +363,7 @@ def load_selenium(args, wire=False):
334
363
  resolved = get_browser_profile_path(browser_name, profile)
335
364
  if resolved:
336
365
  args.user_data_dir = resolved
366
+ args.profile_directory = profile
337
367
  _profile = profile
338
368
  log.info("Using browser profile: %s", resolved)
339
369
  if browser_name == "firefox" and not getattr(args, "chrome", False):
@@ -370,6 +400,16 @@ def load_selenium(args, wire=False):
370
400
 
371
401
  service = Service(log_path=tempfile.mktemp(".geckodriver.log"))
372
402
  options = Options()
403
+
404
+ _using_real_profile = bool(args.user_data_dir)
405
+ if _using_real_profile:
406
+ _real_profile = Path(args.user_data_dir)
407
+ _selenium_profile = Path(tempfile.mkdtemp(prefix="ff_selenium_")) / "profile"
408
+ copy_firefox_profile_for_selenium(_real_profile, _selenium_profile)
409
+ args.selenium_profile_dir = _selenium_profile
410
+ args.user_data_dir = str(_selenium_profile)
411
+ log.info("Selenium profile: %s (cookies from %s)", _selenium_profile, _real_profile)
412
+
373
413
  if args.user_data_dir:
374
414
  options.add_argument("--profile")
375
415
  options.add_argument(args.user_data_dir)
@@ -380,7 +420,7 @@ def load_selenium(args, wire=False):
380
420
 
381
421
  args.driver = webdriver.Firefox(service=service, options=options)
382
422
 
383
- if not args.user_data_dir:
423
+ if not _using_real_profile:
384
424
  addons = [Path("~/.local/lib/ublock_origin.xpi").expanduser().resolve()]
385
425
  if getattr(args, "auto_pager", False):
386
426
  addons.append(Path("~/.local/lib/weautopagerize.xpi").expanduser().resolve())
@@ -401,25 +441,43 @@ def load_selenium(args, wire=False):
401
441
  from selenium.webdriver.chrome.options import Options
402
442
 
403
443
  options = Options()
444
+
445
+ _using_real_profile = bool(args.user_data_dir)
446
+ if _using_real_profile:
447
+ _real_profile = Path(args.user_data_dir)
448
+ _profile_name = _profile or "Default"
449
+ _real_profile_dir = _real_profile / _profile_name
450
+ _selenium_profile = Path(tempfile.mkdtemp(prefix="chrome_selenium_"))
451
+ copy_chromium_profile_for_selenium(_real_profile_dir, _selenium_profile / _profile_name)
452
+ for name in ("Local State",):
453
+ src_file = _real_profile / name
454
+ if src_file.exists():
455
+ shutil.copy2(src_file, _selenium_profile / name)
456
+ args.selenium_profile_dir = _selenium_profile
457
+ args.user_data_dir = str(_selenium_profile)
458
+ log.info("Selenium profile: %s (cookies from %s)", _selenium_profile, _real_profile_dir)
459
+
404
460
  if args.user_data_dir:
405
461
  options.add_argument(f"--user-data-dir={args.user_data_dir}")
406
- if _profile:
462
+ if _using_real_profile:
463
+ options.add_argument(f"--profile-directory={_profile_name}")
464
+ elif _profile:
407
465
  options.add_argument(f"--profile-directory={_profile}")
408
-
409
- options.add_experimental_option(
410
- "prefs",
411
- {
412
- "credentials_enable_service": False,
413
- "profile.password_manager_enabled": False,
414
- "profile.default_content_setting_values.notifications": 2,
415
- },
416
- )
466
+ else:
467
+ options.add_experimental_option(
468
+ "prefs",
469
+ {
470
+ "credentials_enable_service": False,
471
+ "profile.password_manager_enabled": False,
472
+ "profile.default_content_setting_values.notifications": 2,
473
+ },
474
+ )
417
475
  options.add_argument("--disable-notifications")
418
476
  options.add_argument("--mute-audio")
419
477
  if xvfb is False:
420
478
  options.add_argument("--headless=new")
421
479
 
422
- if not args.user_data_dir:
480
+ if not _using_real_profile:
423
481
  addons = [Path("~/.local/lib/ublock_origin.crx").expanduser().resolve()]
424
482
  if getattr(args, "auto_pager", False):
425
483
  addons.append(Path("~/.local/lib/autopager.crx").expanduser().resolve())
@@ -437,6 +495,9 @@ def load_selenium(args, wire=False):
437
495
 
438
496
  def quit_selenium(args):
439
497
  args.driver.quit()
498
+ _selenium_dir = getattr(args, "selenium_profile_dir", None)
499
+ if _selenium_dir:
500
+ shutil.rmtree(_selenium_dir, ignore_errors=True)
440
501
  if args.verbose < consts.LOG_DEBUG and not getattr(args, "manual", False):
441
502
  with suppress(Exception):
442
503
  args.driver_display.stop()
@@ -35,7 +35,7 @@ dynamic = []
35
35
  name = "library"
36
36
  readme = ".github/README.md"
37
37
  requires-python = ">=3.12"
38
- version = "3.2.4"
38
+ version = "3.2.6"
39
39
 
40
40
  [project.license]
41
41
  file = "LICENSE"
File without changes
File without changes