2023-01-14 10:23:50 +00:00
|
|
|
#!/usr/bin/env nix-shell
|
|
|
|
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ps.natsort ps.requests ])"
|
2023-03-26 08:34:27 +00:00
|
|
|
# vim: set filetype=python :
|
2022-12-26 09:05:26 +00:00
|
|
|
"""
|
2023-06-15 10:25:59 +00:00
|
|
|
usage: sane-bt-search [options] <query_string>
|
2022-12-26 09:05:26 +00:00
|
|
|
|
|
|
|
searches Jackett for torrent files matching the title.
|
2023-06-15 10:25:59 +00:00
|
|
|
returns select results and magnet links.
|
|
|
|
|
|
|
|
options:
|
|
|
|
--full display all results
|
|
|
|
--help show this help message and exit
|
2023-07-10 21:53:55 +00:00
|
|
|
--manga show only manga results
|
2023-06-15 10:25:59 +00:00
|
|
|
--json output one json document instead of a human-readable table
|
2023-07-05 23:18:13 +00:00
|
|
|
--top=<n> show the <n> top rated torrents (default: 5)
|
2023-06-15 10:25:59 +00:00
|
|
|
--verbose show more information, useful for debugging/development
|
2022-12-26 09:05:26 +00:00
|
|
|
"""
|
|
|
|
|
2023-06-19 21:01:52 +00:00
|
|
|
# about Jackett
|
|
|
|
# - source: <https://github.com/Jackett/Jackett>
|
|
|
|
# - can be queried via APIs:
|
|
|
|
# - Torznab: <https://torznab.github.io/spec-1.3-draft/index.html>
|
|
|
|
# - TorrentPotato: <https://github.com/RuudBurger/CouchPotatoServer/wiki/Couchpotato-torrent-provider>
|
|
|
|
# - its own JSON-based API
|
|
|
|
|
2022-12-26 09:05:26 +00:00
|
|
|
from dataclasses import dataclass
|
|
|
|
from datetime import datetime
|
2023-06-08 01:32:19 +00:00
|
|
|
import logging
|
2023-04-29 08:59:06 +00:00
|
|
|
import json
|
2022-12-26 09:05:26 +00:00
|
|
|
import natsort
|
|
|
|
import requests
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
|
|
|
|
SERVICE = "https://jackett.uninsane.org"
|
|
|
|
ENDPOINTS = dict(
|
|
|
|
results="api/v2.0/indexers/all/results"
|
2023-06-19 21:01:52 +00:00
|
|
|
# results_torznab="api/v2.0/indexers/all/results/torznab"
|
2022-12-26 09:05:26 +00:00
|
|
|
)
|
|
|
|
|
2023-05-04 00:36:17 +00:00
|
|
|
epoch = datetime(1970, 1, 1)
|
|
|
|
|
2023-06-08 01:32:19 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2023-06-15 10:25:59 +00:00
|
|
|
class BadCliArgs(Exception):
|
|
|
|
def __init__(self, msg: str = None):
|
|
|
|
helpstr = __doc__
|
|
|
|
if msg:
|
|
|
|
super().__init__(f"{msg}\n\n{helpstr}")
|
|
|
|
else:
|
|
|
|
super().__init__(helpstr)
|
|
|
|
|
|
|
|
|
2023-05-04 00:36:17 +00:00
|
|
|
def try_parse_time(t: str):
|
|
|
|
try:
|
|
|
|
return datetime.fromisoformat(t)
|
|
|
|
except ValueError: pass
|
|
|
|
|
|
|
|
if len(t) > len('YYYY-MM-DD'):
|
|
|
|
# sometimes these timestamps are encoded with e.g. too many digits in the milliseconds field.
|
|
|
|
# so just keep chomping until we get something that parses as a timestamp
|
|
|
|
return try_parse_time(t[:-1])
|
|
|
|
|
|
|
|
def parse_time(t: str) -> datetime:
|
|
|
|
return try_parse_time(t).astimezone() or epoch
|
|
|
|
|
|
|
|
|
2023-07-07 07:08:17 +00:00
|
|
|
DROP_CATS = { "dvd", "hd", "misc", "other", "sd" }
|
|
|
|
MANGA_CATS = { "books", "comics", "ebook" }
|
|
|
|
KNOWN_CATS = frozenset(list(MANGA_CATS) + ["anime", "audio", "movies", "tv", "xxx"])
|
|
|
|
def clean_cat(c: str) -> str | None:
|
|
|
|
if c in DROP_CATS: return None
|
|
|
|
return c
|
|
|
|
|
|
|
|
def is_cat(cats: list[str], wanted_cats: list[str], default: bool = False) -> bool:
|
|
|
|
"""
|
|
|
|
return True if any of the `cats` is in `wanted_cats`.
|
|
|
|
in the event there no category is recognized, assume `default`
|
|
|
|
"""
|
|
|
|
if not any(c in KNOWN_CATS for c in cats):
|
|
|
|
return default
|
|
|
|
else:
|
|
|
|
return any(c in wanted_cats for c in cats)
|
|
|
|
|
2023-03-26 08:34:52 +00:00
|
|
|
@dataclass(eq=True, order=True, unsafe_hash=True)
|
2022-12-26 09:05:26 +00:00
|
|
|
class Torrent:
|
|
|
|
seeders: int
|
|
|
|
pub_date: datetime
|
2023-01-20 02:10:07 +00:00
|
|
|
size: int
|
|
|
|
tracker: str
|
2022-12-26 09:05:26 +00:00
|
|
|
title: str
|
2023-07-07 07:12:48 +00:00
|
|
|
magnet: str | None
|
|
|
|
http_dl_uri: str | None # probably a .torrent file but it COULD be a referral to a magnet:// URI
|
|
|
|
tracker_uri: str | None
|
2023-07-07 07:08:17 +00:00
|
|
|
categories: frozenset[str] # human-friendly list of categories, lowercase. e.g. ["Books", "Anime"]
|
2022-12-26 09:05:26 +00:00
|
|
|
|
|
|
|
def __str__(self) -> str:
|
2023-07-07 07:08:17 +00:00
|
|
|
cats = "/".join(self.categories) if self.categories else "?"
|
2023-06-19 21:01:52 +00:00
|
|
|
rows = []
|
2023-07-07 07:08:17 +00:00
|
|
|
rows.append(f"{self.seeders}[S]\t{cats}\t{self.tracker}\t{self.pub_date}\t{self.mib}M\t{self.title}")
|
2023-06-19 21:01:52 +00:00
|
|
|
if self.tracker_uri:
|
|
|
|
rows.append(f"\t{self.tracker_uri}")
|
|
|
|
rows.append(f"\t{self.dl_uri}")
|
|
|
|
return "\n".join(rows)
|
2023-06-08 01:32:19 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def dl_uri(self) -> str:
|
|
|
|
return self.magnet or self.http_dl_uri
|
2023-01-20 02:10:07 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def mib(self) -> int:
|
|
|
|
return int(round(self.size / 1024 / 1024))
|
2022-12-26 09:05:26 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def from_dict(d: dict) -> 'Torrent':
|
2023-06-08 01:32:19 +00:00
|
|
|
logger.debug(f"Torrent.from_dict: fields: { ' '.join(d.keys()) }")
|
|
|
|
for k, v in d.items():
|
2023-07-07 07:08:17 +00:00
|
|
|
if k not in ("CategoryDesc", "Seeders", "PublishDate", "Size", "Tracker", "Title", "MagnetUri", "Guid", "Link", "Details") and \
|
2023-06-08 01:32:19 +00:00
|
|
|
v != None and v != "" and v != [] and v != {}:
|
|
|
|
logger.debug(f" {k} = {v}")
|
|
|
|
|
2022-12-26 09:05:26 +00:00
|
|
|
seeders = d.get("Seeders")
|
|
|
|
pub_date = d.get("PublishDate")
|
2023-01-20 02:10:07 +00:00
|
|
|
size = d.get("Size")
|
|
|
|
tracker = d.get("Tracker")
|
2022-12-26 09:05:26 +00:00
|
|
|
title = d.get("Title")
|
2023-06-08 01:32:19 +00:00
|
|
|
magnet = d.get("MagnetUri") or d.get("Guid")
|
|
|
|
http_dl_uri = d.get("Link")
|
2023-06-19 21:01:52 +00:00
|
|
|
tracker_uri = d.get("Details")
|
2023-07-07 07:08:17 +00:00
|
|
|
categories = d.get("CategoryDesc", "").replace("/", ",").split(",")
|
|
|
|
categories = (c.strip().lower() for c in categories)
|
|
|
|
categories = frozenset(clean_cat(c) for c in categories if clean_cat(c))
|
2023-06-08 01:32:19 +00:00
|
|
|
|
|
|
|
if magnet and not magnet.startswith("magnet:"):
|
|
|
|
logger.info(f"invalid magnet: {magnet}")
|
|
|
|
magnet = None
|
|
|
|
|
|
|
|
if seeders is not None and pub_date is not None and title is not None and (magnet is not None or http_dl_uri is not None):
|
2023-05-04 00:36:17 +00:00
|
|
|
pub_date = parse_time(pub_date)
|
2023-07-07 07:08:17 +00:00
|
|
|
return Torrent(seeders, pub_date, size, tracker, title, magnet, http_dl_uri, tracker_uri, categories=categories)
|
2022-12-26 09:05:26 +00:00
|
|
|
|
2023-04-29 08:59:06 +00:00
|
|
|
def to_dict(self) -> dict:
|
2023-06-19 21:01:52 +00:00
|
|
|
# N.B.: not all fields: needs to be kept in sync with consumers like mx-sanebot
|
2023-04-29 08:59:06 +00:00
|
|
|
return dict(
|
|
|
|
seeders=self.seeders,
|
|
|
|
pub_date=self.pub_date.strftime("%Y-%m-%d"),
|
|
|
|
size=self.size,
|
|
|
|
tracker=self.tracker,
|
|
|
|
title=self.title,
|
|
|
|
magnet=self.magnet,
|
|
|
|
)
|
|
|
|
|
2023-07-07 07:08:17 +00:00
|
|
|
def is_manga(self, default: bool = False) -> bool:
|
|
|
|
return is_cat(self.categories, MANGA_CATS, default)
|
|
|
|
|
2022-12-26 09:05:26 +00:00
|
|
|
class Client:
|
|
|
|
def __init__(self):
|
2023-05-15 10:07:00 +00:00
|
|
|
self.apikey = open("/run/secrets/jackett_apikey").read().strip()
|
2022-12-26 09:05:26 +00:00
|
|
|
|
|
|
|
def api_call(self, method: str, params: dict) -> dict:
|
|
|
|
endpoint = ENDPOINTS[method]
|
|
|
|
url = f"{SERVICE}/{endpoint}"
|
|
|
|
params = params.copy()
|
|
|
|
params.update(apikey=self.apikey, _=str(int(time.time())))
|
|
|
|
resp = requests.get(url, params=params)
|
|
|
|
return resp.json()
|
|
|
|
|
2023-07-07 07:12:48 +00:00
|
|
|
def query(self, q: str) -> list[Torrent]:
|
2023-03-26 08:34:52 +00:00
|
|
|
torrents = set()
|
2022-12-26 09:05:26 +00:00
|
|
|
api_res = self.api_call("results", dict(Query=q))
|
|
|
|
for r in api_res["Results"]:
|
|
|
|
t = Torrent.from_dict(r)
|
|
|
|
if t is not None:
|
2023-03-26 08:34:52 +00:00
|
|
|
torrents.add(t)
|
2022-12-26 09:05:26 +00:00
|
|
|
|
|
|
|
return sorted(torrents, reverse=True)
|
|
|
|
|
2023-07-07 07:08:17 +00:00
|
|
|
|
|
|
|
def filter_results(results: list[Torrent], full: bool, top: int, manga: bool) -> list[Torrent]:
|
|
|
|
"""
|
|
|
|
take the complete query and filter further based on CLI options
|
|
|
|
"""
|
|
|
|
if manga:
|
|
|
|
results = [t for t in results if t.is_manga(default=True)]
|
|
|
|
if not full:
|
|
|
|
results = results[:top]
|
|
|
|
return results
|
|
|
|
|
2023-07-07 07:12:48 +00:00
|
|
|
def parse_args(args: list[str]) -> dict:
|
2023-01-20 02:17:59 +00:00
|
|
|
options = dict(
|
|
|
|
full=False,
|
2023-06-15 10:25:59 +00:00
|
|
|
help=False,
|
2023-04-29 08:59:06 +00:00
|
|
|
json=False,
|
2023-06-15 10:25:59 +00:00
|
|
|
query="",
|
|
|
|
top="5",
|
2023-06-08 01:32:19 +00:00
|
|
|
verbose=False,
|
2023-07-07 07:08:17 +00:00
|
|
|
manga=False,
|
2023-01-20 02:17:59 +00:00
|
|
|
)
|
|
|
|
while args:
|
|
|
|
arg = args[0]
|
|
|
|
del args[0]
|
|
|
|
if arg.startswith('--'):
|
|
|
|
opt = arg[2:]
|
|
|
|
if "=" in opt:
|
|
|
|
name, val = opt.split('=')
|
|
|
|
else:
|
|
|
|
name, val = opt, True
|
|
|
|
options[name] = val
|
|
|
|
else:
|
|
|
|
options["query"] = options["query"] + " " + arg if options["query"] else arg
|
2022-12-26 09:05:26 +00:00
|
|
|
|
2023-01-20 02:17:59 +00:00
|
|
|
return options
|
|
|
|
|
2023-07-07 07:12:48 +00:00
|
|
|
def main(args: list[str]):
|
2023-06-08 01:32:19 +00:00
|
|
|
logging.basicConfig()
|
2023-01-20 02:17:59 +00:00
|
|
|
options = parse_args(args)
|
2023-06-13 08:01:52 +00:00
|
|
|
full = options.pop("full")
|
2023-06-15 10:25:59 +00:00
|
|
|
help = options.pop("help")
|
2023-06-13 08:01:52 +00:00
|
|
|
json = options.pop("json")
|
2023-06-15 10:25:59 +00:00
|
|
|
query = options.pop("query")
|
2023-07-07 07:11:47 +00:00
|
|
|
top = int(options.pop("top"))
|
2023-06-15 10:25:59 +00:00
|
|
|
verbose = options.pop("verbose")
|
2023-07-07 07:08:17 +00:00
|
|
|
manga = options.pop("manga")
|
2023-06-08 01:32:19 +00:00
|
|
|
|
2023-06-15 10:25:59 +00:00
|
|
|
if options != {}:
|
|
|
|
raise BadCliArgs(f"unexpected options: {options}")
|
|
|
|
if help:
|
|
|
|
raise BadCliArgs()
|
2023-06-13 08:01:52 +00:00
|
|
|
|
|
|
|
if verbose:
|
2023-06-08 01:32:19 +00:00
|
|
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
|
|
|
2023-07-07 07:08:17 +00:00
|
|
|
|
2023-01-20 02:17:59 +00:00
|
|
|
client = Client()
|
2023-07-07 07:08:17 +00:00
|
|
|
results = client.query(query)
|
|
|
|
num_results = len(results)
|
|
|
|
|
|
|
|
results = filter_results(results, full, top, manga)
|
|
|
|
|
2023-06-13 08:01:52 +00:00
|
|
|
if json:
|
2023-07-07 07:08:17 +00:00
|
|
|
dumpable = [t.to_dict() for t in results]
|
2023-04-29 08:59:06 +00:00
|
|
|
print(json.dumps(dumpable))
|
|
|
|
else:
|
2023-07-07 07:08:17 +00:00
|
|
|
print(f"found {num_results} result(s)")
|
|
|
|
for r in results:
|
2023-04-29 08:59:06 +00:00
|
|
|
print(r)
|
2023-01-20 02:17:59 +00:00
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main(sys.argv[1:])
|