quasarr 2.5.0__tar.gz → 2.6.1__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.

Potentially problematic release.


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

Files changed (100) hide show
  1. {quasarr-2.5.0 → quasarr-2.6.1}/.github/workflows/PullRequests.yml +5 -5
  2. {quasarr-2.5.0 → quasarr-2.6.1}/.github/workflows/Release.yml +14 -2
  3. {quasarr-2.5.0 → quasarr-2.6.1}/PKG-INFO +1 -1
  4. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/__init__.py +30 -35
  5. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/__init__.py +17 -9
  6. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/arr/__init__.py +15 -6
  7. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/captcha/__init__.py +2 -9
  8. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/config/__init__.py +10 -0
  9. quasarr-2.6.1/quasarr/api/jdownloader/__init__.py +239 -0
  10. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/packages/__init__.py +2 -12
  11. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/__init__.py +2 -0
  12. quasarr-2.6.1/quasarr/downloads/sources/hs.py +131 -0
  13. quasarr-2.6.1/quasarr/downloads/sources/wd.py +169 -0
  14. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/cloudflare.py +46 -1
  15. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/html_templates.py +14 -3
  16. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/sessions/al.py +4 -0
  17. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/shared_state.py +17 -17
  18. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/version.py +1 -1
  19. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/__init__.py +4 -0
  20. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/al.py +17 -13
  21. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/by.py +4 -1
  22. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/dd.py +16 -4
  23. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/dl.py +13 -1
  24. quasarr-2.6.1/quasarr/search/sources/hs.py +515 -0
  25. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/mb.py +1 -7
  26. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/nx.py +4 -1
  27. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/wd.py +33 -6
  28. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/wx.py +10 -8
  29. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/storage/config.py +1 -0
  30. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/storage/setup.py +90 -64
  31. quasarr-2.5.0/quasarr/downloads/sources/wd.py +0 -155
  32. {quasarr-2.5.0 → quasarr-2.6.1}/.github/FUNDING.yml +0 -0
  33. {quasarr-2.5.0 → quasarr-2.6.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  34. {quasarr-2.5.0 → quasarr-2.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  35. {quasarr-2.5.0 → quasarr-2.6.1}/.github/workflows/HostnameRedaction.yml +0 -0
  36. {quasarr-2.5.0 → quasarr-2.6.1}/.gitignore +0 -0
  37. {quasarr-2.5.0 → quasarr-2.6.1}/.pre-commit-config.yaml +0 -0
  38. {quasarr-2.5.0 → quasarr-2.6.1}/CONTRIBUTING.md +0 -0
  39. {quasarr-2.5.0 → quasarr-2.6.1}/LICENSE +0 -0
  40. {quasarr-2.5.0 → quasarr-2.6.1}/Quasarr.png +0 -0
  41. {quasarr-2.5.0 → quasarr-2.6.1}/Quasarr.py +0 -0
  42. {quasarr-2.5.0 → quasarr-2.6.1}/README.md +0 -0
  43. {quasarr-2.5.0 → quasarr-2.6.1}/docker/Dockerfile +0 -0
  44. {quasarr-2.5.0 → quasarr-2.6.1}/docker/dev-services-compose.yml +0 -0
  45. {quasarr-2.5.0 → quasarr-2.6.1}/docker/docker-compose.yml +0 -0
  46. {quasarr-2.5.0 → quasarr-2.6.1}/pre-commit.py +0 -0
  47. {quasarr-2.5.0 → quasarr-2.6.1}/pyproject.toml +0 -0
  48. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/sponsors_helper/__init__.py +0 -0
  49. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/api/statistics/__init__.py +0 -0
  50. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/__init__.py +0 -0
  51. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/al.py +0 -0
  52. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/filecrypt.py +0 -0
  53. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/hide.py +0 -0
  54. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/packages/__init__.py +0 -0
  55. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/__init__.py +0 -0
  56. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/al.py +0 -0
  57. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/by.py +0 -0
  58. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/dd.py +0 -0
  59. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/dj.py +0 -0
  60. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/dl.py +0 -0
  61. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/dt.py +0 -0
  62. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/dw.py +0 -0
  63. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/he.py +0 -0
  64. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/mb.py +0 -0
  65. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/nk.py +0 -0
  66. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/nx.py +0 -0
  67. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/sf.py +0 -0
  68. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/sj.py +0 -0
  69. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/sl.py +0 -0
  70. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/downloads/sources/wx.py +0 -0
  71. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/__init__.py +0 -0
  72. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/auth.py +0 -0
  73. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/hostname_issues.py +0 -0
  74. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/html_images.py +0 -0
  75. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/imdb_metadata.py +0 -0
  76. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/jd_cache.py +0 -0
  77. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/log.py +0 -0
  78. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/myjd_api.py +0 -0
  79. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/notifications.py +0 -0
  80. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/obfuscated.py +0 -0
  81. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/sessions/__init__.py +0 -0
  82. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/sessions/dd.py +0 -0
  83. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/sessions/dl.py +0 -0
  84. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/sessions/nx.py +0 -0
  85. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/statistics.py +0 -0
  86. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/utils.py +0 -0
  87. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/providers/web_server.py +0 -0
  88. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/__init__.py +0 -0
  89. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/dj.py +0 -0
  90. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/dt.py +0 -0
  91. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/dw.py +0 -0
  92. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/fx.py +0 -0
  93. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/he.py +0 -0
  94. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/nk.py +0 -0
  95. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/sf.py +0 -0
  96. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/sj.py +0 -0
  97. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/search/sources/sl.py +0 -0
  98. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/storage/__init__.py +0 -0
  99. {quasarr-2.5.0 → quasarr-2.6.1}/quasarr/storage/sqlite_database.py +0 -0
  100. {quasarr-2.5.0 → quasarr-2.6.1}/uv.lock +0 -0
@@ -53,7 +53,7 @@ jobs:
53
53
 
54
54
  version:
55
55
  needs: [ quality-check ]
56
- if: needs.quality-check.outputs.changes_pushed != 'true' && (github.head_ref || github.ref_name) == 'dev'
56
+ if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev'))
57
57
  runs-on: ubuntu-latest
58
58
  outputs:
59
59
  version: ${{ steps.version.outputs.version }}
@@ -76,7 +76,7 @@ jobs:
76
76
 
77
77
  build-wheel:
78
78
  needs: [ quality-check, version ]
79
- if: needs.quality-check.outputs.changes_pushed != 'true' && (github.head_ref || github.ref_name) == 'dev'
79
+ if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev'))
80
80
  runs-on: ubuntu-latest
81
81
  outputs:
82
82
  attestation-id: ${{ steps.attest.outputs.attestation-id }}
@@ -106,7 +106,7 @@ jobs:
106
106
 
107
107
  build-exe:
108
108
  needs: [ quality-check, version ]
109
- if: needs.quality-check.outputs.changes_pushed != 'true' && (github.head_ref || github.ref_name) == 'dev'
109
+ if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev'))
110
110
  runs-on: windows-latest
111
111
  env:
112
112
  TMP: "D:\\a\\temp"
@@ -194,7 +194,7 @@ jobs:
194
194
 
195
195
  build-docker-amd64:
196
196
  needs: [ quality-check, version, build-wheel ]
197
- if: needs.quality-check.outputs.changes_pushed != 'true' && (github.head_ref || github.ref_name) == 'dev'
197
+ if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev'))
198
198
  runs-on: ubuntu-latest
199
199
  steps:
200
200
  - uses: actions/checkout@v6
@@ -226,7 +226,7 @@ jobs:
226
226
 
227
227
  build-docker-arm64:
228
228
  needs: [ quality-check, version, build-wheel ]
229
- if: needs.quality-check.outputs.changes_pushed != 'true' && (github.head_ref || github.ref_name) == 'dev'
229
+ if: needs.quality-check.outputs.changes_pushed != 'true' && ((github.head_ref == 'dev' && github.base_ref == 'main') || (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev'))
230
230
  runs-on: ubuntu-24.04-arm
231
231
  steps:
232
232
  - uses: actions/checkout@v6
@@ -254,18 +254,24 @@ jobs:
254
254
  uses: metcalfc/changelog-generator@v4.6.2
255
255
  with:
256
256
  myToken: ${{ secrets.GITHUB_TOKEN }}
257
- - name: Add PR Body to Changelog
257
+ - name: Get PR Body
258
258
  env:
259
259
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
260
260
  run: |
261
261
  PR_BODY=$(gh pr list --search "${{ github.sha }}" --state merged --json body --jq '.[0].body // empty')
262
- if [ -n "$PR_BODY" ]; then echo -e "\n$PR_BODY" >> .github/Changelog.md; fi
262
+ if [ -n "$PR_BODY" ]; then
263
+ echo "$PR_BODY" > pr_body.txt
264
+ fi
263
265
  - name: Create Release Body
264
266
  run: |
265
267
  echo "### Docker:" > release_body.md
266
268
  echo "\`docker pull ${{ env.GHCR_ENDPOINT }}:latest\`" >> release_body.md
267
269
  echo "### Python:" >> release_body.md
268
270
  echo "\`uv tool upgrade quasarr\`" >> release_body.md
271
+ if [ -f pr_body.txt ]; then
272
+ cat pr_body.txt >> release_body.md
273
+ echo "" >> release_body.md
274
+ fi
269
275
  echo "### Changelog:" >> release_body.md
270
276
  echo "${{ steps.changelog.outputs.changelog }}" >> release_body.md
271
277
  echo "[Attestation](https://github.com/${{ github.repository }}/attestations/${{ needs.build-wheel.outputs.attestation-id }})" >> release_body.md
@@ -293,6 +299,12 @@ jobs:
293
299
  run: |
294
300
  TAG="v.$VERSION"
295
301
  RELEASE_BODY=$(gh release view "$TAG" --json body --jq .body)
302
+
303
+ # Truncate to 4000 characters to avoid Discord limits
304
+ if [ ${#RELEASE_BODY} -gt 4000 ]; then
305
+ RELEASE_BODY="${RELEASE_BODY:0:4000}..."
306
+ fi
307
+
296
308
  if [ -n "$DISCORD_WEBHOOK" ]; then
297
309
  jq -n --arg title "🚀 New Release: $TAG" --arg desc "$RELEASE_BODY" --arg url "https://github.com/$REPO/releases/tag/$TAG" '{content: null, embeds: [{title: $title, description: $desc, url: $url, color: 5763719}]}' > discord_payload.json
298
310
  curl -H "Content-Type: application/json" -d @discord_payload.json "$DISCORD_WEBHOOK"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 2.5.0
3
+ Version: 2.6.1
4
4
  Summary: Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
5
5
  Author-email: rix1337 <rix1337@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -386,48 +386,43 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
386
386
  try:
387
387
  shared_state.set_state(shared_state_dict, shared_state_lock)
388
388
 
389
- shared_state.set_device_from_config()
389
+ while True:
390
+ shared_state.set_device_from_config()
390
391
 
391
- connection_established = (
392
- shared_state.get_device() and shared_state.get_device().name
393
- )
394
- if not connection_established:
395
- i = 0
396
- while i < 10:
397
- i += 1
392
+ device = shared_state.get_device()
393
+
394
+ try:
398
395
  info(
399
- f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"'
396
+ f'Connection to JDownloader successful. Device name: "{device.name}"'
400
397
  )
401
- time.sleep(60)
402
- shared_state.set_device_from_config()
403
- connection_established = (
404
- shared_state.get_device() and shared_state.get_device().name
405
- )
406
- if connection_established:
407
- break
398
+ except Exception as e:
399
+ info(f"Error connecting to JDownloader: {e}! Stopping Quasarr...")
400
+ sys.exit(1)
408
401
 
409
- try:
410
- info(
411
- f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"'
412
- )
413
- except Exception as e:
414
- info(f"Error connecting to JDownloader: {e}! Stopping Quasarr!")
415
- sys.exit(1)
402
+ try:
403
+ shared_state.set_device_settings()
404
+ except Exception as e:
405
+ print(f"Error checking settings: {e}")
416
406
 
417
- try:
418
- shared_state.set_device_settings()
419
- except Exception as e:
420
- print(f"Error checking settings: {e}")
407
+ try:
408
+ shared_state.update_jdownloader()
409
+ except Exception as e:
410
+ print(f"Error updating JDownloader: {e}")
421
411
 
422
- try:
423
- shared_state.update_jdownloader()
424
- except Exception as e:
425
- print(f"Error updating JDownloader: {e}")
412
+ try:
413
+ shared_state.start_downloads()
414
+ except Exception as e:
415
+ print(f"Error starting downloads: {e}")
426
416
 
427
- try:
428
- shared_state.start_downloads()
429
- except Exception as e:
430
- print(f"Error starting downloads: {e}")
417
+ while True:
418
+ time.sleep(300)
419
+ device_state = shared_state.check_device(
420
+ shared_state.values.get("device")
421
+ )
422
+ if not device_state:
423
+ info("Lost connection to JDownloader. Reconnecting...")
424
+ shared_state.update("device", False)
425
+ break
431
426
 
432
427
  except KeyboardInterrupt:
433
428
  pass
@@ -8,6 +8,7 @@ import quasarr.providers.html_images as images
8
8
  from quasarr.api.arr import setup_arr_routes
9
9
  from quasarr.api.captcha import setup_captcha_routes
10
10
  from quasarr.api.config import setup_config
11
+ from quasarr.api.jdownloader import get_jdownloader_modal_script, get_jdownloader_status
11
12
  from quasarr.api.packages import setup_packages_routes
12
13
  from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
13
14
  from quasarr.api.statistics import setup_statistics
@@ -49,12 +50,9 @@ def get_api(shared_state_dict, shared_state_lock):
49
50
  protected = shared_state.get_db("protected").retrieve_all_titles()
50
51
  api_key = Config("API").get("key")
51
52
 
52
- # Get quick status summary
53
- try:
54
- device = shared_state.values.get("device")
55
- jd_connected = device is not None and device is not False
56
- except:
57
- jd_connected = False
53
+ # Get JDownloader status and modal script
54
+ jd_status = get_jdownloader_status(shared_state)
55
+ jd_modal_script = get_jdownloader_modal_script()
58
56
 
59
57
  # Calculate hostname status
60
58
  hostnames_config = Config("Hostnames")
@@ -126,10 +124,14 @@ def get_api(shared_state_dict, shared_state_lock):
126
124
  # Status bars
127
125
  status_bars = f"""
128
126
  <div class="status-bar">
129
- <span class="status-pill {"success" if jd_connected else "error"}">
130
- {"" if jd_connected else "❌"} JDownloader {"connected" if jd_connected else "disconnected"}
127
+ <span class="status-pill {jd_status["status_class"]}"
128
+ onclick="openJDownloaderModal()"
129
+ title="Click to configure JDownloader">
130
+ {jd_status["status_text"]}
131
131
  </span>
132
- <span class="status-pill {hostname_status_class}">
132
+ <span class="status-pill {hostname_status_class}"
133
+ onclick="location.href='/hostnames'"
134
+ title="Click to configure Hostnames">
133
135
  {hostname_status_emoji} {hostname_status_text}
134
136
  </span>
135
137
  </div>
@@ -209,6 +211,11 @@ def get_api(shared_state_dict, shared_state_lock):
209
211
  padding: 8px 16px;
210
212
  border-radius: 0.5rem;
211
213
  font-weight: 500;
214
+ transition: transform 0.1s ease;
215
+ cursor: pointer;
216
+ }}
217
+ .status-pill:hover {{
218
+ transform: scale(1.05);
212
219
  }}
213
220
  .status-pill.success {{
214
221
  background: var(--status-success-bg, #e8f5e9);
@@ -478,6 +485,7 @@ def get_api(shared_state_dict, shared_state_lock):
478
485
  );
479
486
  }}
480
487
  </script>
488
+ {jd_modal_script}
481
489
  """
482
490
  # Add logout link for form auth
483
491
  logout_html = '<a href="/logout">Logout</a>' if show_logout_link() else ""
@@ -56,6 +56,16 @@ def parse_payload(payload_str):
56
56
 
57
57
 
58
58
  def setup_arr_routes(app):
59
+ def check_user_agent():
60
+ user_agent = request.headers.get("User-Agent") or ""
61
+ if not any(
62
+ tool in user_agent.lower() for tool in ["radarr", "sonarr", "lazylibrarian"]
63
+ ):
64
+ msg = f"Unsupported User-Agent: {user_agent}. Quasarr as a compatibility layer must be called by Radarr, Sonarr or LazyLibrarian directly."
65
+ info(msg)
66
+ abort(406, msg)
67
+ return user_agent
68
+
59
69
  @app.get("/download/")
60
70
  def fake_nzb_file():
61
71
  payload = request.query.payload
@@ -75,6 +85,7 @@ def setup_arr_routes(app):
75
85
  @app.post("/api")
76
86
  @require_api_key
77
87
  def download_fake_nzb_file():
88
+ request_from = check_user_agent()
78
89
  downloads = request.files.getall("name")
79
90
  nzo_ids = [] # naming structure for package IDs expected in newznab
80
91
 
@@ -97,7 +108,6 @@ def setup_arr_routes(app):
97
108
  source_key = root.find(".//file").attrib.get("source_key") or None
98
109
 
99
110
  info(f'Attempting download for "{title}"')
100
- request_from = request.headers.get("User-Agent")
101
111
  downloaded = download(
102
112
  shared_state,
103
113
  request_from,
@@ -128,6 +138,8 @@ def setup_arr_routes(app):
128
138
  @app.get("/api/<mirror>")
129
139
  @require_api_key
130
140
  def quasarr_api(mirror=None):
141
+ request_from = check_user_agent()
142
+
131
143
  api_type = (
132
144
  "arr_download_client"
133
145
  if request.query.mode
@@ -198,7 +210,6 @@ def setup_arr_routes(app):
198
210
 
199
211
  nzo_ids = []
200
212
  info(f'Attempting download for "{parsed_payload["title"]}"')
201
- request_from = "lazylibrarian"
202
213
 
203
214
  downloaded = download(
204
215
  shared_state,
@@ -267,8 +278,6 @@ def setup_arr_routes(app):
267
278
  )
268
279
 
269
280
  mode = request.query.t
270
- request_from = request.headers.get("User-Agent")
271
-
272
281
  if mode == "caps":
273
282
  info(f"Providing indexer capability information to {request_from}")
274
283
  return """<?xml version="1.0" encoding="UTF-8"?>
@@ -352,10 +361,10 @@ def setup_arr_routes(app):
352
361
  mirror=mirror,
353
362
  )
354
363
  else:
355
- info(
364
+ # sonarr expects this but we will not support non-imdbid searches
365
+ debug(
356
366
  f"Ignoring search request from {request_from} - only imdbid searches are supported"
357
367
  )
358
- releases = [] # sonarr expects this but we will not support non-imdbid searches
359
368
 
360
369
  items = ""
361
370
  for release in releases:
@@ -11,6 +11,7 @@ import requests
11
11
  from bottle import HTTPResponse, redirect, request, response
12
12
 
13
13
  import quasarr.providers.html_images as images
14
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
14
15
  from quasarr.downloads.linkcrypters.filecrypt import DLC, get_filecrypt_links
15
16
  from quasarr.downloads.packages import delete_package
16
17
  from quasarr.providers import obfuscated, shared_state
@@ -46,15 +47,7 @@ def setup_captcha_routes(app):
46
47
  except KeyError:
47
48
  device = None
48
49
  if not device:
49
- return render_centered_html(f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
50
- <div class="status-bar">
51
- <span class="status-pill error">
52
- ❌ JDownloader disconnected
53
- </span>
54
- </div>
55
- <p>
56
- {render_button("Back", "secondary", {"onclick": "location.href='/'"})}
57
- </p>''')
50
+ return get_jdownloader_disconnected_page(shared_state)
58
51
 
59
52
  protected = shared_state.get_db("protected").retrieve_all_titles()
60
53
  if not protected:
@@ -22,6 +22,8 @@ from quasarr.storage.setup import (
22
22
  import_hostnames_from_url,
23
23
  save_flaresolverr_url,
24
24
  save_hostnames,
25
+ save_jdownloader_settings,
26
+ verify_jdownloader_credentials,
25
27
  )
26
28
  from quasarr.storage.sqlite_database import DataBase
27
29
 
@@ -217,3 +219,11 @@ def setup_config(app, shared_state):
217
219
 
218
220
  threading.Thread(target=delayed_exit, daemon=True).start()
219
221
  return {"success": True, "message": "Restarting..."}
222
+
223
+ @app.post("/api/jdownloader/verify")
224
+ def verify_jdownloader_api():
225
+ return verify_jdownloader_credentials(shared_state)
226
+
227
+ @app.post("/api/jdownloader/save")
228
+ def save_jdownloader_api():
229
+ return save_jdownloader_settings(shared_state, is_setup=False)
@@ -0,0 +1,239 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ from quasarr.providers.html_templates import render_button
6
+ from quasarr.storage.config import Config
7
+
8
+
9
+ def get_jdownloader_status(shared_state):
10
+ """Get JDownloader connection status and device name."""
11
+ try:
12
+ device = shared_state.values.get("device")
13
+ jd_connected = device is not None and device is not False
14
+ except:
15
+ jd_connected = False
16
+
17
+ jd_config = Config("JDownloader")
18
+ jd_device = jd_config.get("device") or ""
19
+
20
+ dev_name = jd_device if jd_device else "JDownloader"
21
+ dev_name_safe = (
22
+ dev_name.replace("&", "&amp;")
23
+ .replace("<", "&lt;")
24
+ .replace(">", "&gt;")
25
+ .replace('"', "&quot;")
26
+ )
27
+
28
+ if jd_connected:
29
+ status_text = f"✅ {dev_name_safe} connected"
30
+ status_class = "success"
31
+ elif jd_device:
32
+ status_text = f"❌ {dev_name_safe} disconnected"
33
+ status_class = "error"
34
+ else:
35
+ status_text = "❌ JDownloader disconnected"
36
+ status_class = "error"
37
+
38
+ return {
39
+ "connected": jd_connected,
40
+ "device_name": jd_device,
41
+ "status_text": status_text,
42
+ "status_class": status_class,
43
+ }
44
+
45
+
46
+ def get_jdownloader_modal_script():
47
+ """Return the JavaScript for the JDownloader configuration modal."""
48
+ jd_config = Config("JDownloader")
49
+ jd_user = jd_config.get("user") or ""
50
+ jd_pass = jd_config.get("password") or ""
51
+ jd_device = jd_config.get("device") or ""
52
+
53
+ jd_user_js = jd_user.replace("\\", "\\\\").replace("'", "\\'")
54
+ jd_pass_js = jd_pass.replace("\\", "\\\\").replace("'", "\\'")
55
+ jd_device_js = jd_device.replace("\\", "\\\\").replace("'", "\\'")
56
+
57
+ return f"""
58
+ <script>
59
+ function openJDownloaderModal() {{
60
+ var currentUser = '{jd_user_js}';
61
+ var currentPass = '{jd_pass_js}';
62
+ var currentDevice = '{jd_device_js}';
63
+
64
+ var content = `
65
+ <div id="jd-step-1">
66
+ <input type="hidden" id="jd-current-device" value="${{currentDevice}}">
67
+ <p><strong>JDownloader must be running and connected to My JDownloader!</strong></p>
68
+ <div style="margin-bottom: 1rem;">
69
+ <label style="display:block; font-size: 0.875rem;">E-Mail</label>
70
+ <input type="text" id="jd-user" value="${{currentUser}}" placeholder="user@example.org" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
71
+ </div>
72
+ <div style="margin-bottom: 1rem;">
73
+ <label style="display:block; font-size: 0.875rem;">Password</label>
74
+ <input type="password" id="jd-pass" value="${{currentPass}}" placeholder="Password" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
75
+ </div>
76
+ <div id="jd-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
77
+ <button class="btn-primary" onclick="verifyJDCredentials()">Verify Credentials</button>
78
+ </div>
79
+
80
+ <div id="jd-step-2" style="display:none;">
81
+ <p>Select your JDownloader instance:</p>
82
+ <div style="margin-bottom: 1rem;">
83
+ <select id="jd-device" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;"></select>
84
+ </div>
85
+ <div id="jd-save-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
86
+ <button class="btn-primary" onclick="saveJDSettings()">Save</button>
87
+ </div>
88
+ `;
89
+
90
+ showModal('Configure JDownloader', content, '<button class="btn-secondary" onclick="closeModal()">Close</button>');
91
+ }}
92
+
93
+ function verifyJDCredentials() {{
94
+ var user = document.getElementById('jd-user').value;
95
+ var pass = document.getElementById('jd-pass').value;
96
+ var statusDiv = document.getElementById('jd-status');
97
+
98
+ statusDiv.innerHTML = 'Verifying...';
99
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
100
+
101
+ fetch('/api/jdownloader/verify', {{
102
+ method: 'POST',
103
+ headers: {{ 'Content-Type': 'application/json' }},
104
+ body: JSON.stringify({{ user: user, pass: pass }})
105
+ }})
106
+ .then(response => response.json())
107
+ .then(data => {{
108
+ if (data.success) {{
109
+ var select = document.getElementById('jd-device');
110
+ select.innerHTML = '';
111
+ var currentDevice = document.getElementById('jd-current-device').value;
112
+ data.devices.forEach(device => {{
113
+ var opt = document.createElement('option');
114
+ opt.value = device;
115
+ opt.innerHTML = device;
116
+ if (device === currentDevice) {{
117
+ opt.selected = true;
118
+ }}
119
+ select.appendChild(opt);
120
+ }});
121
+
122
+ document.getElementById('jd-step-1').style.display = 'none';
123
+ document.getElementById('jd-step-2').style.display = 'block';
124
+ }} else {{
125
+ statusDiv.innerHTML = '❌ ' + (data.message || 'Verification failed');
126
+ statusDiv.style.color = '#dc3545';
127
+ }}
128
+ }})
129
+ .catch(error => {{
130
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
131
+ statusDiv.style.color = '#dc3545';
132
+ }});
133
+ }}
134
+
135
+ function saveJDSettings() {{
136
+ var user = document.getElementById('jd-user').value;
137
+ var pass = document.getElementById('jd-pass').value;
138
+ var device = document.getElementById('jd-device').value;
139
+ var statusDiv = document.getElementById('jd-save-status');
140
+
141
+ statusDiv.innerHTML = 'Saving...';
142
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
143
+
144
+ fetch('/api/jdownloader/save', {{
145
+ method: 'POST',
146
+ headers: {{ 'Content-Type': 'application/json' }},
147
+ body: JSON.stringify({{ user: user, pass: pass, device: device }})
148
+ }})
149
+ .then(response => response.json())
150
+ .then(data => {{
151
+ if (data.success) {{
152
+ statusDiv.innerHTML = '✅ ' + data.message;
153
+ statusDiv.style.color = '#198754';
154
+ setTimeout(function() {{
155
+ window.location.reload();
156
+ }}, 1000);
157
+ }} else {{
158
+ statusDiv.innerHTML = '❌ ' + data.message;
159
+ statusDiv.style.color = '#dc3545';
160
+ }}
161
+ }})
162
+ .catch(error => {{
163
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
164
+ statusDiv.style.color = '#dc3545';
165
+ }});
166
+ }}
167
+ </script>
168
+ """
169
+
170
+
171
+ def get_jdownloader_status_pill(shared_state):
172
+ """Return the HTML for the JDownloader status pill."""
173
+ status = get_jdownloader_status(shared_state)
174
+
175
+ return f"""
176
+ <span class="status-pill {status["status_class"]}"
177
+ onclick="openJDownloaderModal()"
178
+ style="cursor: pointer;"
179
+ title="Click to configure JDownloader">
180
+ {status["status_text"]}
181
+ </span>
182
+ """
183
+
184
+
185
+ def get_jdownloader_disconnected_page(shared_state, back_url="/"):
186
+ """Return a full error page when JDownloader is disconnected."""
187
+ import quasarr.providers.html_images as images
188
+ from quasarr.providers.html_templates import render_centered_html
189
+
190
+ status_pill = get_jdownloader_status_pill(shared_state)
191
+ modal_script = get_jdownloader_modal_script()
192
+
193
+ back_btn = render_button(
194
+ "Back", "secondary", {"onclick": f"location.href='{back_url}'"}
195
+ )
196
+
197
+ content = f'''
198
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
199
+ <div class="status-bar">
200
+ {status_pill}
201
+ </div>
202
+ <p>{back_btn}</p>
203
+ <style>
204
+ .status-pill {{
205
+ font-size: 0.9em;
206
+ padding: 8px 16px;
207
+ border-radius: 0.5rem;
208
+ font-weight: 500;
209
+ transition: transform 0.1s ease;
210
+ }}
211
+ .status-pill:hover {{
212
+ transform: scale(1.05);
213
+ }}
214
+ .status-pill.success {{
215
+ background: var(--status-success-bg, #e8f5e9);
216
+ color: var(--status-success-color, #2e7d32);
217
+ border: 1px solid var(--status-success-border, #a5d6a7);
218
+ }}
219
+ .status-pill.error {{
220
+ background: var(--status-error-bg, #ffebee);
221
+ color: var(--status-error-color, #c62828);
222
+ border: 1px solid var(--status-error-border, #ef9a9a);
223
+ }}
224
+ /* Dark mode */
225
+ @media (prefers-color-scheme: dark) {{
226
+ :root {{
227
+ --status-success-bg: #1c4532;
228
+ --status-success-color: #68d391;
229
+ --status-success-border: #276749;
230
+ --status-error-bg: #3d2d2d;
231
+ --status-error-color: #fc8181;
232
+ --status-error-border: #c53030;
233
+ }}
234
+ }}
235
+ </style>
236
+ {modal_script}
237
+ '''
238
+
239
+ return render_centered_html(content)
@@ -3,6 +3,7 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import quasarr.providers.html_images as images
6
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
6
7
  from quasarr.downloads.packages import delete_package, get_packages
7
8
  from quasarr.providers import shared_state
8
9
  from quasarr.providers.html_templates import render_button, render_centered_html
@@ -349,18 +350,7 @@ def setup_packages_routes(app):
349
350
  device = None
350
351
 
351
352
  if not device:
352
- back_btn = render_button(
353
- "Back", "secondary", {"onclick": "location.href='/'"}
354
- )
355
- return render_centered_html(f'''
356
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
357
- <div class="status-bar">
358
- <span class="status-pill error">
359
- ❌ JDownloader disconnected
360
- </span>
361
- </div>
362
- <p>{back_btn}</p>
363
- ''')
353
+ return get_jdownloader_disconnected_page(shared_state)
364
354
 
365
355
  # Check for delete status from redirect
366
356
  deleted = request.query.get("deleted")
@@ -16,6 +16,7 @@ from quasarr.downloads.sources.dl import get_dl_download_links
16
16
  from quasarr.downloads.sources.dt import get_dt_download_links
17
17
  from quasarr.downloads.sources.dw import get_dw_download_links
18
18
  from quasarr.downloads.sources.he import get_he_download_links
19
+ from quasarr.downloads.sources.hs import get_hs_download_links
19
20
  from quasarr.downloads.sources.mb import get_mb_download_links
20
21
  from quasarr.downloads.sources.nk import get_nk_download_links
21
22
  from quasarr.downloads.sources.nx import get_nx_download_links
@@ -57,6 +58,7 @@ SOURCE_GETTERS = {
57
58
  "dt": get_dt_download_links,
58
59
  "dw": get_dw_download_links,
59
60
  "he": get_he_download_links,
61
+ "hs": get_hs_download_links,
60
62
  "mb": get_mb_download_links,
61
63
  "nk": get_nk_download_links,
62
64
  "nx": get_nx_download_links,