Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

<img src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" data-canonical-src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" width="64" height="64" />

Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
Quasarr connects JDownloader with Radarr, Sonarr, Lidarr and LazyLibrarian. It also decrypts links protected by
CAPTCHAs.

[![PyPI version](https://badge.fury.io/py/quasarr.svg)](https://badge.fury.io/py/quasarr)
[![Discord](https://img.shields.io/discord/1075348594225315891)](https://discord.gg/eM4zA2wWQb)
Expand Down Expand Up @@ -48,7 +49,8 @@ login required).
## JDownloader

> ⚠️ If using Docker:
> JDownloader's download path must be available to Radarr/Sonarr/LazyLibrarian with **identical internal and external
> JDownloader's download path must be available to Radarr/Sonarr/Lidarr/LazyLibrarian with **identical internal and
external
path mappings**!
> Matching only the external path is not sufficient.

Expand All @@ -59,7 +61,7 @@ path mappings**!
<summary>Fresh install recommended</summary>

Consider setting up a fresh JDownloader instance. Quasarr will modify JDownloader's settings to enable
Radarr/Sonarr/LazyLibrarian integration.
Radarr/Sonarr/Lidarr/LazyLibrarian integration.

</details>

Expand All @@ -71,29 +73,33 @@ You can manage categories in the Quasarr Web UI.

* **Setup:** Add or edit categories to organize your downloads.
* **Download Mirror Whitelist:**
* Inside a category, you can whitelist specific mirrors.
* Inside a **download category**, you can whitelist specific mirrors.
* If specific mirrors are set, downloads will fail unless the release is available from them.
* This does not affect search results.
* If specific mirrors are set, downloads will fail unless the release contains them.
* This affects the **Quasarr Download Client** in Radarr/Sonarr/Lidarr/LazyLibrarian.
* **Search Hostname Whitelist:**
* Inside a category, you can whitelist specific hostnames.
* Inside a **search category**, you can whitelist specific hostnames.
* If specific hostnames are set, only these will be searched by the given search category.
* This affects search results.
* If specific hostnames are set, only these will be searched by the given category.
* This affects the **Quasarr Newznab Indexer** in Radarr/Sonarr/Lidarr/LazyLibrarian.
* **Custom Search Categories:** You can add up to 10 custom search categories per base type (Movies, TV, Music, Books). These allow you to create separate hostname whitelists for different purposes.
* **Emoji:** Will be used in the Packages view on the Quasarr Web UI.

---

## Radarr / Sonarr
## Radarr / Sonarr / Lidarr

> ⚠️ **Sonarr users:** Set all shows (including anime) to the **Standard** series type. Quasarr cannot find releases for
> shows set to Anime/Absolute.


Add Quasarr as both a **Newznab Indexer** and **SABnzbd Download Client** using your Quasarr URL and API Key.

Be sure to set a category in the **SABnzbd Download client** (default: `movies` for Radarr and `tv` for Sonarr).
Be sure to set a category in the **SABnzbd Download client** (default: `movies` for Radarr, `tv` for Sonarr and `music`
for Lidarr).

<details>
<summary>Show download status in Radarr/Sonarr</summary>
<summary>Show download status in Radarr/Sonarr/Lidarr</summary>

**Activity → Queue → Options** → Enable `Release Title`

Expand All @@ -116,9 +122,9 @@ Add Quasarr as a **Generic Newznab Indexer**.
* Use IMDb ID, Syntax: `{ImdbId:tt0133093}` and pick category `2000` (Movies) or `5000` (TV)
* Simple text search is **not** supported.

#### Books/Magazines:
#### Music / Books / Magazines:

* Use simple text search and pick category`7000` (Books/Magazines).
* Use simple text search and pick category `3000` (Music) or `7000` (Books/Magazines).

</details>

Expand Down Expand Up @@ -182,15 +188,15 @@ docker run -d \
ghcr.io/rix1337/quasarr:latest
```

| Parameter | Description |
|--------------------|-----------------------------------------------------------------------------------------------------|
| `INTERNAL_ADDRESS` | **Required.** Internal URL so Radarr/Sonarr/LazyLibrarian can reach Quasarr. **Must include port.** |
| `EXTERNAL_ADDRESS` | Optional. External URL (e.g. reverse proxy). Always protect external access with authentication. |
| `DISCORD` | Optional. Discord webhook URL for notifications. |
| `USER` / `PASS` | Optional, but recommended! Username / Password to protect the web UI. |
| `AUTH` | Authentication mode. Supported values: `form` or `basic`. |
| `SILENT` | Optional. If `True`, silences all Discord notifications except SponsorHelper error messages. ||
| `TZ` | Optional. Timezone. Incorrect values may cause HTTPS/SSL issues. |
| Parameter | Description |
|--------------------|------------------------------------------------------------------------------------------------------------|
| `INTERNAL_ADDRESS` | **Required.** Internal URL so Radarr/Sonarr/Lidarr/LazyLibrarian can reach Quasarr. **Must include port.** |
| `EXTERNAL_ADDRESS` | Optional. External URL (e.g. reverse proxy). Always protect external access with authentication. |
| `DISCORD` | Optional. Discord webhook URL for notifications. |
| `USER` / `PASS` | Optional, but recommended! Username / Password to protect the web UI. |
| `AUTH` | Authentication mode. Supported values: `form` or `basic`. |
| `SILENT` | Optional. If `True`, silences all Discord notifications except SponsorHelper error messages. ||
| `TZ` | Optional. Timezone. Incorrect values may cause HTTPS/SSL issues. |

# Manual setup

Expand All @@ -217,9 +223,9 @@ Complexity is the killer of small projects like this one. It must be fought at a
We will not waste precious time on features that will slow future development cycles down.
Most feature requests can be satisfied by:

- Existing settings in Radarr/Sonarr/LazyLibrarian
- Existing settings in Radarr/Sonarr/Lidarr/LazyLibrarian
- Existing settings in JDownloader
- Existing tools from the *arr ecosystem that integrate directly with Radarr/Sonarr/LazyLibrarian
- Existing tools from the *arr ecosystem that integrate directly with Radarr/Sonarr/Lidarr/LazyLibrarian

# Roadmap

Expand Down
88 changes: 71 additions & 17 deletions cli_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
DEFAULT_URL = "http://localhost:8080"
USER_AGENT_RADARR = "Radarr/3.0.0.0 (Mock Client for Testing)"
USER_AGENT_SONARR = "Sonarr/3.0.0.0 (Mock Client for Testing)"
USER_AGENT_LIDARR = "Lidarr/3.0.0.0 (Mock Client for Testing)"
USER_AGENT_LL = "LazyLibrarian/1.7.0 (Mock Client for Testing)"

console = Console()
Expand Down Expand Up @@ -91,7 +92,9 @@ def get_text(self):
spinner = self.frames[frame_idx]

lines = []
lines.append(HTML(f"<b><ansicyan>--- {self.title.upper()} ---</ansicyan></b>"))
lines.append(
HTML(f"<b><ansicyan>--- {escape(self.title.upper())} ---</ansicyan></b>")
)
lines.append(HTML("<grey>Keys: [Backspace/←] Cancel Operation</grey>"))
lines.append(HTML(""))

Expand Down Expand Up @@ -256,28 +259,30 @@ def get_text(self):
total_pages = max(1, (len(self.items) + self.page_size - 1) // self.page_size)

lines = []
lines.append(HTML(f"<b><ansicyan>--- {self.title.upper()} ---</ansicyan></b>"))
lines.append(
HTML(f"<b><ansicyan>--- {escape(self.title.upper())} ---</ansicyan></b>")
)

# Header with Sort Info
current_sort_key = self.allowed_sorts[self.sort_index]
sort_label = self.all_sort_logic.get(current_sort_key, ("Unknown",))[0]

lines.append(
HTML(
f"<grey>Keys: [↑/↓] Nav | [Enter] Select | [Tab] Sort: <b>{sort_label}</b> | [Back] Exit</grey>"
f"<grey>Keys: [↑/↓] Nav | [Enter] Select | [Tab] Sort: <b>{escape(sort_label)}</b> | [Back] Exit</grey>"
)
)

summary = f"Page {page_idx + 1}/{total_pages} (Total: {len(self.items)})"
if self.duration is not None:
summary += f" | Took {self.duration:.2f}s"

lines.append(HTML(f"<i>{summary}</i>"))
lines.append(HTML(f"<i>{escape(summary)}</i>"))
lines.append(HTML(""))

for i in range(start, end):
label, _ = self.items[i]
safe_label = label.replace("<", "&lt;").replace(">", "&gt;")
safe_label = escape(label)

if i == self.selected_index:
lines.append(HTML(f"<reverse> {safe_label} </reverse>"))
Expand Down Expand Up @@ -373,7 +378,9 @@ def __init__(self, title, items, allow_back=True):

def get_text(self):
lines = []
lines.append(HTML(f"<b><ansicyan>--- {self.title.upper()} ---</ansicyan></b>"))
lines.append(
HTML(f"<b><ansicyan>--- {escape(self.title.upper())} ---</ansicyan></b>")
)

if self.allow_back:
lines.append(
Expand All @@ -391,7 +398,7 @@ def get_text(self):
lines.append(HTML(""))

for i, (label, _) in enumerate(self.items):
safe_label = label.replace("<", "&lt;").replace(">", "&gt;")
safe_label = escape(label)
if i == self.selected_index:
lines.append(HTML(f"<reverse> {safe_label} </reverse>"))
else:
Expand Down Expand Up @@ -489,7 +496,7 @@ def _(event):
while True:
try:
prompt_text = HTML(
f"<b><ansicyan>--- {self.title.upper()} ---</ansicyan></b>\n<grey>Keys: [Enter] Confirm | [Esc] Cancel</grey>\n[{self.default}]: "
f"<b><ansicyan>--- {escape(self.title.upper())} ---</ansicyan></b>\n<grey>Keys: [Enter] Confirm | [Esc] Cancel</grey>\n[{escape(self.default)}]: "
)
result = session.prompt(prompt_text, default=self.default)

Expand Down Expand Up @@ -534,7 +541,7 @@ def _(event):
kb.add(key)(exit_app)

text_control = FormattedTextControl(
text=HTML(f"<grey>{message}</grey>"), show_cursor=False
text=HTML(f"<grey>{escape(message)}</grey>"), show_cursor=False
)
layout = Layout(HSplit([Window(content=text_control, height=1)]))
app = Application(layout=layout, key_bindings=kb, full_screen=False)
Expand Down Expand Up @@ -592,6 +599,10 @@ def get_feed(self, feed_type):
return self._parse_xml(
self._get({"t": "tvsearch", "cat": "5000"}, USER_AGENT_SONARR)
)
elif feed_type == "music":
return self._parse_xml(
self._get({"t": "music", "cat": "3000"}, USER_AGENT_LIDARR)
)
elif feed_type == "doc":
return self._parse_xml(
self._get({"t": "book", "cat": "7000"}, USER_AGENT_LL)
Expand All @@ -611,6 +622,11 @@ def search_tv(self, imdb_id, season=None, ep=None):
params["ep"] = ep
return self._fetch_all_results(params, USER_AGENT_SONARR)

def search_music(self, query):
return self._fetch_all_results(
{"t": "music", "title": query, "cat": "3000"}, USER_AGENT_LIDARR
)

def search_doc(self, query):
return self._fetch_all_results(
{"t": "book", "title": query, "cat": "7000"}, USER_AGENT_LL
Expand Down Expand Up @@ -671,6 +687,8 @@ def add_download(self, title, link, category=None):
user_agent = USER_AGENT_RADARR
if category == "tv":
user_agent = USER_AGENT_SONARR
elif category == "music":
user_agent = USER_AGENT_LIDARR
elif category == "docs":
user_agent = USER_AGENT_LL

Expand Down Expand Up @@ -946,6 +964,7 @@ def handle_feeds_menu(client):
[
("🎬 Movie (Radarr)", "movie"),
("📺 TV (Sonarr)", "tv"),
("🎵 Music (Lidarr)", "music"),
("📄 Doc (LazyLib)", "doc"),
],
).run()
Expand All @@ -961,21 +980,27 @@ def handle_feeds_menu(client):
clear_screen()

if results is not None:
cat_map = {"movie": "movies", "tv": "tv", "doc": "docs"}
cat_map = {"movie": "movies", "tv": "tv", "music": "music", "doc": "docs"}
handle_results_pager(
client, results, cat_map.get(choice), time.time() - start
)


def handle_searches_menu(client):
defaults = {"movie": "tt0133093", "tv": "tt0944947", "doc": "PC Gamer UK"}
defaults = {
"movie": "tt0133093",
"tv": "tt0944947",
"music": "Taylor Swift",
"doc": "PC Gamer UK",
}
while True:
clear_screen()
choice = MenuSelector(
"Select Search Type",
[
("🎬 Movie (IMDb)", "movie"),
("📺 TV (IMDb)", "tv"),
("🎵 Music (Query)", "music"),
("📄 Doc (Query)", "doc"),
],
).run()
Expand Down Expand Up @@ -1016,6 +1041,17 @@ def handle_searches_menu(client):
clear_screen()
if res is not None:
handle_results_pager(client, res, "tv", time.time() - start)
elif choice == "music":
q = TextInput("Music: Query", default=defaults["music"]).run()
if q:
clear_screen()
start = time.time()
res = LoadingScreen(
f"Searching Music: {q}", client.search_music, q
).run()
clear_screen()
if res is not None:
handle_results_pager(client, res, "music", time.time() - start)
elif choice == "doc":
q = TextInput("Doc: Query", default=defaults["doc"]).run()
if q:
Expand All @@ -1035,7 +1071,7 @@ def handle_hostname_test(client, interactive=True):
console.print("[dim]Keys: [Ctrl+C] Cancel Operation[/dim]")
console.print("")

feeds = [("movie", "movies"), ("tv", "tv"), ("doc", "docs")]
feeds = [("movie", "movies"), ("tv", "tv"), ("music", "music"), ("doc", "docs")]

# 1. Fetch Feeds
console.print("[cyan]Fetching feeds...[/cyan]")
Expand Down Expand Up @@ -1273,6 +1309,13 @@ def process_download_task(task):
"del_success": 0,
"del_fail": 0,
},
"music": {
"results": 0,
"dl_success": 0,
"dl_fail": 0,
"del_success": 0,
"del_fail": 0,
},
"doc": {
"results": 0,
"dl_success": 0,
Expand Down Expand Up @@ -1415,7 +1458,7 @@ def process_download_task(task):
table.add_column("Downloads (Success/Total)", justify="right")
table.add_column("Deletions (Success/Total)", justify="right")

for feed in ["movie", "tv", "doc"]:
for feed in ["movie", "tv", "music", "doc"]:
data = stats[feed]
total_dl = data["results"]
dl_success = data["dl_success"]
Expand All @@ -1432,16 +1475,16 @@ def process_download_task(task):
)
del_style = (
"green"
if del_success == total_del and total_del > 0
if del_success == total_del and total_dl > 0
else "red"
if del_success == 0 and total_del > 0
if del_success == 0 and total_dl > 0
else "yellow"
)

table.add_row(
feed.upper(),
f"[{dl_style}]{dl_success}/{total_dl}[/{dl_style}]",
f"[{del_style}]{del_success}/{total_del}[/{del_style}]",
f"[{del_style}]{del_success}/{total_dl}[/{del_style}]",
)
console.print(table)
console.print("")
Expand Down Expand Up @@ -1499,20 +1542,31 @@ def run_cli():
parser.add_argument("--key")
parser.add_argument("--test-movie", action="store_true")
parser.add_argument("--test-tv", action="store_true")
parser.add_argument("--test-music", action="store_true")
parser.add_argument("--test-doc", action="store_true")
parser.add_argument("--test-hostnames", action="store_true")
args = parser.parse_args()

api_key = args.key or os.environ.get("QUASARR_API_KEY")

if any([args.test_movie, args.test_tv, args.test_doc, args.test_hostnames]):
if any(
[
args.test_movie,
args.test_tv,
args.test_music,
args.test_doc,
args.test_hostnames,
]
):
if not api_key:
sys.exit("API Key required for tests")
client = QuasarrClient(args.url, api_key)
if args.test_movie:
sys.exit(0 if client.search_movie("tt0133093") else 1)
if args.test_tv:
sys.exit(0 if client.search_tv("tt0944947") else 1)
if args.test_music:
sys.exit(0 if client.search_music("Linkin Park") else 1)
if args.test_doc:
sys.exit(0 if client.search_doc("PC Gamer UK") else 1)
if args.test_hostnames:
Expand Down
Loading