library 3.2.3__tar.gz → 3.2.5__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.3 → library-3.2.5}/.github/README.md +12 -2
  2. {library-3.2.3 → library-3.2.5}/PKG-INFO +13 -3
  3. {library-3.2.3 → library-3.2.5}/library/__main__.py +3 -2
  4. {library-3.2.3 → library-3.2.5}/library/fsdb/disk_usage.py +11 -6
  5. {library-3.2.3 → library-3.2.5}/library/mediafiles/torrents_start.py +86 -17
  6. {library-3.2.3 → library-3.2.5}/library/usage.py +11 -1
  7. {library-3.2.3 → library-3.2.5}/library/utils/arggroups.py +6 -0
  8. {library-3.2.3 → library-3.2.5}/library/utils/filter_engine.py +2 -2
  9. {library-3.2.3 → library-3.2.5}/library/utils/processes.py +4 -4
  10. {library-3.2.3 → library-3.2.5}/library/utils/web.py +118 -20
  11. {library-3.2.3 → library-3.2.5}/pyproject.toml +1 -1
  12. {library-3.2.3 → library-3.2.5}/LICENSE +0 -0
  13. {library-3.2.3 → library-3.2.5}/library/__init__.py +0 -0
  14. {library-3.2.3 → library-3.2.5}/library/assets/__init__.py +0 -0
  15. {library-3.2.3 → library-3.2.5}/library/assets/calibre.css +0 -0
  16. {library-3.2.3 → library-3.2.5}/library/assets/kotobago.png +0 -0
  17. {library-3.2.3 → library-3.2.5}/library/createdb/__init__.py +0 -0
  18. {library-3.2.3 → library-3.2.5}/library/createdb/av.py +0 -0
  19. {library-3.2.3 → library-3.2.5}/library/createdb/computer_info.py +0 -0
  20. {library-3.2.3 → library-3.2.5}/library/createdb/computers_add.py +0 -0
  21. {library-3.2.3 → library-3.2.5}/library/createdb/fs_add.py +0 -0
  22. {library-3.2.3 → library-3.2.5}/library/createdb/fs_add_metadata.py +0 -0
  23. {library-3.2.3 → library-3.2.5}/library/createdb/gallery_add.py +0 -0
  24. {library-3.2.3 → library-3.2.5}/library/createdb/gallery_backend.py +0 -0
  25. {library-3.2.3 → library-3.2.5}/library/createdb/getty_add.py +0 -0
  26. {library-3.2.3 → library-3.2.5}/library/createdb/hn_add.py +0 -0
  27. {library-3.2.3 → library-3.2.5}/library/createdb/links_add.py +0 -0
  28. {library-3.2.3 → library-3.2.5}/library/createdb/nicotine_import.py +0 -0
  29. {library-3.2.3 → library-3.2.5}/library/createdb/places_import.py +0 -0
  30. {library-3.2.3 → library-3.2.5}/library/createdb/reddit_add.py +0 -0
  31. {library-3.2.3 → library-3.2.5}/library/createdb/row_add.py +0 -0
  32. {library-3.2.3 → library-3.2.5}/library/createdb/site_add.py +0 -0
  33. {library-3.2.3 → library-3.2.5}/library/createdb/substack.py +0 -0
  34. {library-3.2.3 → library-3.2.5}/library/createdb/subtitle.py +0 -0
  35. {library-3.2.3 → library-3.2.5}/library/createdb/tables_add.py +0 -0
  36. {library-3.2.3 → library-3.2.5}/library/createdb/tabs_add.py +0 -0
  37. {library-3.2.3 → library-3.2.5}/library/createdb/tildes.py +0 -0
  38. {library-3.2.3 → library-3.2.5}/library/createdb/torrents_add.py +0 -0
  39. {library-3.2.3 → library-3.2.5}/library/createdb/tube_add.py +0 -0
  40. {library-3.2.3 → library-3.2.5}/library/createdb/tube_backend.py +0 -0
  41. {library-3.2.3 → library-3.2.5}/library/createdb/web_add.py +0 -0
  42. {library-3.2.3 → library-3.2.5}/library/data/__init__.py +0 -0
  43. {library-3.2.3 → library-3.2.5}/library/data/dictionary.pkl +0 -0
  44. {library-3.2.3 → library-3.2.5}/library/data/ffmpeg_errors.py +0 -0
  45. {library-3.2.3 → library-3.2.5}/library/data/http_errors.py +0 -0
  46. {library-3.2.3 → library-3.2.5}/library/data/imagemagick_errors.py +0 -0
  47. {library-3.2.3 → library-3.2.5}/library/data/unar_errors.py +0 -0
  48. {library-3.2.3 → library-3.2.5}/library/data/wordbank.py +0 -0
  49. {library-3.2.3 → library-3.2.5}/library/data/yt_dlp_errors.py +0 -0
  50. {library-3.2.3 → library-3.2.5}/library/editdb/__init__.py +0 -0
  51. {library-3.2.3 → library-3.2.5}/library/editdb/dedupe_db.py +0 -0
  52. {library-3.2.3 → library-3.2.5}/library/editdb/dedupe_media.py +0 -0
  53. {library-3.2.3 → library-3.2.5}/library/editdb/merge_online_local.py +0 -0
  54. {library-3.2.3 → library-3.2.5}/library/editdb/mpv_watchlater.py +0 -0
  55. {library-3.2.3 → library-3.2.5}/library/editdb/pushshift.py +0 -0
  56. {library-3.2.3 → library-3.2.5}/library/editdb/reddit_selftext.py +0 -0
  57. {library-3.2.3 → library-3.2.5}/library/files/__init__.py +0 -0
  58. {library-3.2.3 → library-3.2.5}/library/files/christen.py +0 -0
  59. {library-3.2.3 → library-3.2.5}/library/files/sample_compare.py +0 -0
  60. {library-3.2.3 → library-3.2.5}/library/files/sample_hash.py +0 -0
  61. {library-3.2.3 → library-3.2.5}/library/files/similar_files.py +0 -0
  62. {library-3.2.3 → library-3.2.5}/library/folders/__init__.py +0 -0
  63. {library-3.2.3 → library-3.2.5}/library/folders/big_dirs.py +0 -0
  64. {library-3.2.3 → library-3.2.5}/library/folders/filter_src.py +0 -0
  65. {library-3.2.3 → library-3.2.5}/library/folders/merge_mv.py +0 -0
  66. {library-3.2.3 → library-3.2.5}/library/folders/mergerfs_cp.py +0 -0
  67. {library-3.2.3 → library-3.2.5}/library/folders/mount_stats.py +0 -0
  68. {library-3.2.3 → library-3.2.5}/library/folders/move_list.py +0 -0
  69. {library-3.2.3 → library-3.2.5}/library/folders/scatter.py +0 -0
  70. {library-3.2.3 → library-3.2.5}/library/folders/similar_folders.py +0 -0
  71. {library-3.2.3 → library-3.2.5}/library/fsdb/__init__.py +0 -0
  72. {library-3.2.3 → library-3.2.5}/library/fsdb/filesystem.py +0 -0
  73. {library-3.2.3 → library-3.2.5}/library/fsdb/folder_stats.py +0 -0
  74. {library-3.2.3 → library-3.2.5}/library/fsdb/search_db.py +0 -0
  75. {library-3.2.3 → library-3.2.5}/library/lb.py +0 -0
  76. {library-3.2.3 → library-3.2.5}/library/mediadb/__init__.py +0 -0
  77. {library-3.2.3 → library-3.2.5}/library/mediadb/block.py +0 -0
  78. {library-3.2.3 → library-3.2.5}/library/mediadb/db_history.py +0 -0
  79. {library-3.2.3 → library-3.2.5}/library/mediadb/db_media.py +0 -0
  80. {library-3.2.3 → library-3.2.5}/library/mediadb/db_playlists.py +0 -0
  81. {library-3.2.3 → library-3.2.5}/library/mediadb/download.py +0 -0
  82. {library-3.2.3 → library-3.2.5}/library/mediadb/download_status.py +0 -0
  83. {library-3.2.3 → library-3.2.5}/library/mediadb/history.py +0 -0
  84. {library-3.2.3 → library-3.2.5}/library/mediadb/history_add.py +0 -0
  85. {library-3.2.3 → library-3.2.5}/library/mediadb/optimize_db.py +0 -0
  86. {library-3.2.3 → library-3.2.5}/library/mediadb/playlists.py +0 -0
  87. {library-3.2.3 → library-3.2.5}/library/mediadb/redownload.py +0 -0
  88. {library-3.2.3 → library-3.2.5}/library/mediadb/search.py +0 -0
  89. {library-3.2.3 → library-3.2.5}/library/mediadb/stats.py +0 -0
  90. {library-3.2.3 → library-3.2.5}/library/mediafiles/__init__.py +0 -0
  91. {library-3.2.3 → library-3.2.5}/library/mediafiles/images_to_pdf.py +0 -0
  92. {library-3.2.3 → library-3.2.5}/library/mediafiles/media_check.py +0 -0
  93. {library-3.2.3 → library-3.2.5}/library/mediafiles/pdf_edit.py +0 -0
  94. {library-3.2.3 → library-3.2.5}/library/mediafiles/process_ffmpeg.py +0 -0
  95. {library-3.2.3 → library-3.2.5}/library/mediafiles/process_image.py +0 -0
  96. {library-3.2.3 → library-3.2.5}/library/mediafiles/process_media.py +0 -0
  97. {library-3.2.3 → library-3.2.5}/library/mediafiles/process_text.py +0 -0
  98. {library-3.2.3 → library-3.2.5}/library/mediafiles/torrents_dump.py +0 -0
  99. {library-3.2.3 → library-3.2.5}/library/mediafiles/unardel.py +0 -0
  100. {library-3.2.3 → library-3.2.5}/library/misc/__init__.py +0 -0
  101. {library-3.2.3 → library-3.2.5}/library/misc/dedupe_czkawka.py +0 -0
  102. {library-3.2.3 → library-3.2.5}/library/misc/export_text.py +0 -0
  103. {library-3.2.3 → library-3.2.5}/library/misc/search_help.py +0 -0
  104. {library-3.2.3 → library-3.2.5}/library/multidb/__init__.py +0 -0
  105. {library-3.2.3 → library-3.2.5}/library/multidb/allocate_torrents.py +0 -0
  106. {library-3.2.3 → library-3.2.5}/library/multidb/copy_play_counts.py +0 -0
  107. {library-3.2.3 → library-3.2.5}/library/multidb/merge_dbs.py +0 -0
  108. {library-3.2.3 → library-3.2.5}/library/playback/__init__.py +0 -0
  109. {library-3.2.3 → library-3.2.5}/library/playback/links_open.py +0 -0
  110. {library-3.2.3 → library-3.2.5}/library/playback/media_player.py +0 -0
  111. {library-3.2.3 → library-3.2.5}/library/playback/media_printer.py +0 -0
  112. {library-3.2.3 → library-3.2.5}/library/playback/play_actions.py +0 -0
  113. {library-3.2.3 → library-3.2.5}/library/playback/playback_control.py +0 -0
  114. {library-3.2.3 → library-3.2.5}/library/playback/post_actions.py +0 -0
  115. {library-3.2.3 → library-3.2.5}/library/playback/surf.py +0 -0
  116. {library-3.2.3 → library-3.2.5}/library/playback/tabs_open.py +0 -0
  117. {library-3.2.3 → library-3.2.5}/library/playback/torrents_info.py +0 -0
  118. {library-3.2.3 → library-3.2.5}/library/playback/torrents_remaining.py +0 -0
  119. {library-3.2.3 → library-3.2.5}/library/readme.py +0 -0
  120. {library-3.2.3 → library-3.2.5}/library/tablefiles/__init__.py +0 -0
  121. {library-3.2.3 → library-3.2.5}/library/tablefiles/columns.py +0 -0
  122. {library-3.2.3 → library-3.2.5}/library/tablefiles/eda.py +0 -0
  123. {library-3.2.3 → library-3.2.5}/library/tablefiles/incremental_diff.py +0 -0
  124. {library-3.2.3 → library-3.2.5}/library/tablefiles/markdown_tables.py +0 -0
  125. {library-3.2.3 → library-3.2.5}/library/tablefiles/mcda.py +0 -0
  126. {library-3.2.3 → library-3.2.5}/library/tablefiles/plot.py +0 -0
  127. {library-3.2.3 → library-3.2.5}/library/text/__init__.py +0 -0
  128. {library-3.2.3 → library-3.2.5}/library/text/cluster_sort.py +0 -0
  129. {library-3.2.3 → library-3.2.5}/library/text/combinations.py +0 -0
  130. {library-3.2.3 → library-3.2.5}/library/text/expand_links.py +0 -0
  131. {library-3.2.3 → library-3.2.5}/library/text/extract_links.py +0 -0
  132. {library-3.2.3 → library-3.2.5}/library/text/extract_text.py +0 -0
  133. {library-3.2.3 → library-3.2.5}/library/text/json_keys_rename.py +0 -0
  134. {library-3.2.3 → library-3.2.5}/library/text/markdown_links.py +0 -0
  135. {library-3.2.3 → library-3.2.5}/library/text/nouns.py +0 -0
  136. {library-3.2.3 → library-3.2.5}/library/text/regex_sort.py +0 -0
  137. {library-3.2.3 → library-3.2.5}/library/text/timestamps.py +0 -0
  138. {library-3.2.3 → library-3.2.5}/library/utils/__init__.py +0 -0
  139. {library-3.2.3 → library-3.2.5}/library/utils/arg_utils.py +0 -0
  140. {library-3.2.3 → library-3.2.5}/library/utils/argparse_utils.py +0 -0
  141. {library-3.2.3 → library-3.2.5}/library/utils/consts.py +0 -0
  142. {library-3.2.3 → library-3.2.5}/library/utils/date_utils.py +0 -0
  143. {library-3.2.3 → library-3.2.5}/library/utils/db_utils.py +0 -0
  144. {library-3.2.3 → library-3.2.5}/library/utils/devices.py +0 -0
  145. {library-3.2.3 → library-3.2.5}/library/utils/file_utils.py +0 -0
  146. {library-3.2.3 → library-3.2.5}/library/utils/gui.py +0 -0
  147. {library-3.2.3 → library-3.2.5}/library/utils/iterables.py +0 -0
  148. {library-3.2.3 → library-3.2.5}/library/utils/log_utils.py +0 -0
  149. {library-3.2.3 → library-3.2.5}/library/utils/mpv_utils.py +0 -0
  150. {library-3.2.3 → library-3.2.5}/library/utils/nums.py +0 -0
  151. {library-3.2.3 → library-3.2.5}/library/utils/objects.py +0 -0
  152. {library-3.2.3 → library-3.2.5}/library/utils/path_utils.py +0 -0
  153. {library-3.2.3 → library-3.2.5}/library/utils/pd_utils.py +0 -0
  154. {library-3.2.3 → library-3.2.5}/library/utils/printing.py +0 -0
  155. {library-3.2.3 → library-3.2.5}/library/utils/remote_processes.py +0 -0
  156. {library-3.2.3 → library-3.2.5}/library/utils/shell_utils.py +0 -0
  157. {library-3.2.3 → library-3.2.5}/library/utils/sql_utils.py +0 -0
  158. {library-3.2.3 → library-3.2.5}/library/utils/sqlgroups.py +0 -0
  159. {library-3.2.3 → library-3.2.5}/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.003; 102 subcommands)
102
+ library (v3.2.005; 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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: library
3
- Version: 3.2.3
3
+ Version: 3.2.5
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.003; 102 subcommands)
210
+ library (v3.2.005; 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
 
@@ -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.003"
8
+ __version__ = "3.2.005"
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,5 +1,4 @@
1
- import getpass, logging, shutil, time
2
- from contextlib import suppress
1
+ import getpass, os, shutil, time
3
2
  from pathlib import Path
4
3
  from time import sleep
5
4
 
@@ -65,9 +64,15 @@ def start_qBittorrent(args):
65
64
  HTTPADAPTER_ARGS={"pool_connections": 32, "pool_maxsize": 32},
66
65
  )
67
66
 
68
- with suppress(Exception):
67
+ try:
69
68
  qbt_client.auth_log_in()
70
69
  return qbt_client
70
+ except qbittorrentapi.LoginFailed:
71
+ # qBittorrent may allow API access with WebUI authentication disabled.
72
+ if args.username is None and args.password is None:
73
+ return qbt_client
74
+ except (qbittorrentapi.APIConnectionError, ConnectionRefusedError):
75
+ pass
71
76
 
72
77
  if shutil.which("qbittorrent-nox"):
73
78
  username = getpass.getuser()
@@ -85,40 +90,104 @@ def start_qBittorrent(args):
85
90
  log.debug("qBittorrent web UI ready")
86
91
  break
87
92
  except qbittorrentapi.LoginFailed as excinfo:
88
- logging.warning("Authentication failed. Check your qBit settings, --username, and --password: %s", excinfo)
93
+ log.warning("Authentication failed. Check your qBit settings, --username, and --password: %s", excinfo)
89
94
  break # stop if authentication failing
90
95
  except (qbittorrentapi.APIConnectionError, ConnectionRefusedError):
91
96
  time.sleep(2)
92
97
  attempt += 1
93
98
  else:
94
- logging.error("Failed to connect to qBittorrent web UI")
99
+ log.error("Failed to connect to qBittorrent web UI")
95
100
  raise ConnectionError("qBittorrent web UI not available")
96
101
 
97
102
  return qbt_client
98
103
 
99
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
+
100
153
  def torrents_start():
101
154
  args = parse_args()
102
155
 
103
156
  qbt_client = start_qBittorrent(args)
104
157
 
105
158
  if args.temp_drive and Path(args.temp_drive).is_absolute():
106
- temp_prefix = Path(args.temp_drive)
159
+ temp_drive = Path(args.temp_drive)
107
160
  else:
108
- temp_prefix = Path(args.download_drive)
109
- temp_prefix /= args.temp_prefix
110
- download_prefix = Path(args.download_drive) / args.download_prefix
161
+ temp_drive = Path(args.download_drive)
111
162
 
112
- 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:
113
180
  torrent = torrent_decode(path)
114
181
 
115
- download_path = download_prefix
116
- temp_path = temp_prefix
117
- if args.tracker_dirnames:
118
- tracker = get_tracker_domain(torrent)
119
- if tracker:
120
- download_path /= tracker
121
- 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
122
191
 
123
192
  qbt_client.torrents_add(
124
193
  torrent_files=path,
@@ -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 ...
@@ -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):
@@ -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
@@ -262,7 +262,7 @@ def get_browser_profile_path(browser_name, profile=None):
262
262
  browser_name = browser_name.lower()
263
263
 
264
264
  if browser_name == "firefox":
265
- profile_roots = list(_firefox_browser_dirs())
265
+ profile_roots = [root for root in _firefox_browser_dirs() if os.path.isfile(os.path.join(root, "profiles.ini"))]
266
266
  if not profile_roots:
267
267
  return None
268
268
  root = profile_roots[0]
@@ -270,6 +270,9 @@ def get_browser_profile_path(browser_name, profile=None):
270
270
  candidate = os.path.join(root, profile)
271
271
  if os.path.isdir(candidate):
272
272
  return candidate
273
+ default_profile = _firefox_default_profile(root)
274
+ if default_profile and os.path.isdir(os.path.join(root, default_profile)):
275
+ return os.path.join(root, default_profile)
273
276
  return root
274
277
 
275
278
  if browser_name in {"chrome", "chromium", "brave", "edge", "opera", "vivaldi", "whale"}:
@@ -277,22 +280,82 @@ def get_browser_profile_path(browser_name, profile=None):
277
280
  browser_dir = settings["browser_dir"]
278
281
  if not os.path.isdir(browser_dir):
279
282
  return None
280
- if profile:
281
- candidate = os.path.join(browser_dir, profile)
282
- if os.path.isdir(candidate):
283
- return candidate
284
- default_profile = os.path.join(browser_dir, "Default")
285
- if os.path.isdir(default_profile):
286
- return default_profile
283
+ # selenium passes this via --user-data-dir, which expects the data root;
284
+ # chrome selects its own last-used profile unless told otherwise
287
285
  return browser_dir
288
286
 
289
287
  return None
290
288
 
291
289
 
290
+ def _firefox_default_profile(root):
291
+ import configparser
292
+
293
+ def read_ini(ini_name):
294
+ ini_path = os.path.join(root, ini_name)
295
+ if not os.path.isfile(ini_path):
296
+ return None
297
+ parser = configparser.RawConfigParser()
298
+ try:
299
+ with open(ini_path, encoding="utf8") as f:
300
+ parser.read_file(f)
301
+ except (configparser.Error, OSError):
302
+ return None
303
+ return parser
304
+
305
+ installs = read_ini("installs.ini")
306
+ if installs:
307
+ for section in installs.sections():
308
+ if section.lower().startswith("install"):
309
+ default = installs.get(section, "Default", fallback=None)
310
+ if default and os.path.isdir(os.path.join(root, default)):
311
+ return default
312
+
313
+ profiles = read_ini("profiles.ini")
314
+ if not profiles:
315
+ return None
316
+ sections = [s for s in profiles.sections() if s.lower().startswith("profile")]
317
+ for section in sections:
318
+ if profiles.get(section, "Default", fallback="") == "1":
319
+ path = profiles.get(section, "Path", fallback=None)
320
+ if path and os.path.isdir(os.path.join(root, path)):
321
+ return path
322
+ return next((p for p in (profiles.get(s, "Path", fallback=None) for s in sections) if p), None)
323
+
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
+
292
354
  def load_selenium(args, wire=False):
293
355
  if getattr(args, "driver", False):
294
356
  return
295
357
 
358
+ _profile = None
296
359
  if not args.user_data_dir and getattr(args, "cookies_from_browser", None):
297
360
  from library.utils.web import parse_cookies_from_browser
298
361
 
@@ -300,6 +363,8 @@ def load_selenium(args, wire=False):
300
363
  resolved = get_browser_profile_path(browser_name, profile)
301
364
  if resolved:
302
365
  args.user_data_dir = resolved
366
+ args.profile_directory = profile
367
+ _profile = profile
303
368
  log.info("Using browser profile: %s", resolved)
304
369
  if browser_name == "firefox" and not getattr(args, "chrome", False):
305
370
  args.firefox = True
@@ -335,6 +400,16 @@ def load_selenium(args, wire=False):
335
400
 
336
401
  service = Service(log_path=tempfile.mktemp(".geckodriver.log"))
337
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
+
338
413
  if args.user_data_dir:
339
414
  options.add_argument("--profile")
340
415
  options.add_argument(args.user_data_dir)
@@ -345,7 +420,7 @@ def load_selenium(args, wire=False):
345
420
 
346
421
  args.driver = webdriver.Firefox(service=service, options=options)
347
422
 
348
- if not args.user_data_dir:
423
+ if not _using_real_profile:
349
424
  addons = [Path("~/.local/lib/ublock_origin.xpi").expanduser().resolve()]
350
425
  if getattr(args, "auto_pager", False):
351
426
  addons.append(Path("~/.local/lib/weautopagerize.xpi").expanduser().resolve())
@@ -366,23 +441,43 @@ def load_selenium(args, wire=False):
366
441
  from selenium.webdriver.chrome.options import Options
367
442
 
368
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
+
369
460
  if args.user_data_dir:
370
461
  options.add_argument(f"--user-data-dir={args.user_data_dir}")
371
-
372
- options.add_experimental_option(
373
- "prefs",
374
- {
375
- "credentials_enable_service": False,
376
- "profile.password_manager_enabled": False,
377
- "profile.default_content_setting_values.notifications": 2,
378
- },
379
- )
462
+ if _using_real_profile:
463
+ options.add_argument(f"--profile-directory={_profile_name}")
464
+ elif _profile:
465
+ options.add_argument(f"--profile-directory={_profile}")
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
+ )
380
475
  options.add_argument("--disable-notifications")
381
476
  options.add_argument("--mute-audio")
382
477
  if xvfb is False:
383
478
  options.add_argument("--headless=new")
384
479
 
385
- if not args.user_data_dir:
480
+ if not _using_real_profile:
386
481
  addons = [Path("~/.local/lib/ublock_origin.crx").expanduser().resolve()]
387
482
  if getattr(args, "auto_pager", False):
388
483
  addons.append(Path("~/.local/lib/autopager.crx").expanduser().resolve())
@@ -400,6 +495,9 @@ def load_selenium(args, wire=False):
400
495
 
401
496
  def quit_selenium(args):
402
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)
403
501
  if args.verbose < consts.LOG_DEBUG and not getattr(args, "manual", False):
404
502
  with suppress(Exception):
405
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.3"
38
+ version = "3.2.5"
39
39
 
40
40
  [project.license]
41
41
  file = "LICENSE"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes