first commit
This commit is contained in:
commit
233b64a51d
14 changed files with 936 additions and 0 deletions
100
grab.py
Normal file
100
grab.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SHOWS_ROOT = Path(__file__).resolve().parent
|
||||||
|
DOWNLOADS = SHOWS_ROOT / ".downloads"
|
||||||
|
SEED_RATIO = 2.0
|
||||||
|
SEED_TIME_MIN = 1440
|
||||||
|
PORT = 6881
|
||||||
|
VIDEO_EXTS = {'.mkv', '.mp4', '.avi', '.mov', '.m4v', '.ts', '.webm'}
|
||||||
|
|
||||||
|
def extract_ep(name):
|
||||||
|
stem = Path(name).stem
|
||||||
|
stem = re.sub(r'^\[[^\]]+\]\s*', '', stem)
|
||||||
|
stem = re.sub(r'\([^)]*\)', '', stem)
|
||||||
|
stem = re.sub(r'\[[^\]]*\]', '', stem)
|
||||||
|
if re.search(r'\b(NCOP|NCED|OP|ED|Menu|Extra|Preview|PV|SP|Special)\b', stem, re.I):
|
||||||
|
return None
|
||||||
|
m = re.search(r'(?:^|\s|-)\s*(\d{1,3})(?:v\d)?(?=\s|$|\.)', stem)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
def organize(stage, name, year, season):
|
||||||
|
show_dir = SHOWS_ROOT / f"{name} ({year})"
|
||||||
|
season_dir = show_dir / f"Season {int(season):02d}"
|
||||||
|
specials_dir = show_dir / "Specials"
|
||||||
|
season_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for f in Path(stage).rglob('*'):
|
||||||
|
if not f.is_file() or f.suffix.lower() not in VIDEO_EXTS:
|
||||||
|
continue
|
||||||
|
ep = extract_ep(f.name)
|
||||||
|
if ep is None:
|
||||||
|
specials_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst = specials_dir / f.name
|
||||||
|
else:
|
||||||
|
dst = season_dir / f"{name} - S{int(season):02d}E{ep:02d}{f.suffix.lower()}"
|
||||||
|
if dst.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.link(f, dst)
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(f, dst)
|
||||||
|
print(f"[organize] {dst.relative_to(SHOWS_ROOT)}", flush=True)
|
||||||
|
|
||||||
|
def cmd_organize(a):
|
||||||
|
organize(a.stage, a.name, a.year, a.season)
|
||||||
|
|
||||||
|
def cmd_download(a):
|
||||||
|
torrent = Path(a.torrent).expanduser().resolve()
|
||||||
|
if not torrent.exists():
|
||||||
|
sys.exit(f"not found: {torrent}")
|
||||||
|
stage = DOWNLOADS / torrent.stem
|
||||||
|
stage.mkdir(parents=True, exist_ok=True)
|
||||||
|
hook = stage / "_hook.sh"
|
||||||
|
hook.write_text(
|
||||||
|
f'#!/bin/bash\n'
|
||||||
|
f'python3 "{Path(__file__).resolve()}" organize '
|
||||||
|
f'--stage "{stage}" --name "{a.name}" --year "{a.year}" --season {a.season}\n'
|
||||||
|
)
|
||||||
|
hook.chmod(0o755)
|
||||||
|
cmd = [
|
||||||
|
"aria2c", str(torrent),
|
||||||
|
f"--dir={stage}",
|
||||||
|
f"--listen-port={PORT}", f"--dht-listen-port={PORT}",
|
||||||
|
"--enable-dht=true", "--enable-peer-exchange=true", "--bt-enable-lpd=true",
|
||||||
|
"--follow-torrent=mem", "--check-integrity=true",
|
||||||
|
"--summary-interval=30", "--console-log-level=warn",
|
||||||
|
f"--on-bt-download-complete={hook}",
|
||||||
|
]
|
||||||
|
if a.no_seed:
|
||||||
|
cmd.append("--seed-time=0")
|
||||||
|
else:
|
||||||
|
cmd += [f"--seed-ratio={SEED_RATIO}", f"--seed-time={SEED_TIME_MIN}"]
|
||||||
|
subprocess.run(cmd)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
d = sub.add_parser("download")
|
||||||
|
d.add_argument("torrent")
|
||||||
|
d.add_argument("--name", required=True)
|
||||||
|
d.add_argument("--year", required=True)
|
||||||
|
d.add_argument("--season", type=int, default=1)
|
||||||
|
d.add_argument("--no-seed", action="store_true")
|
||||||
|
d.set_defaults(func=cmd_download)
|
||||||
|
o = sub.add_parser("organize")
|
||||||
|
o.add_argument("--stage", required=True)
|
||||||
|
o.add_argument("--name", required=True)
|
||||||
|
o.add_argument("--year", required=True)
|
||||||
|
o.add_argument("--season", type=int, default=1)
|
||||||
|
o.set_defaults(func=cmd_organize)
|
||||||
|
a = ap.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
13
nyaa-tools-old/README.md
Normal file
13
nyaa-tools-old/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# [nyaa-tools](https://sr.ht/~truita/nyaa-tools)
|
||||||
|
`nyaa-tools` is a set of scripts for automating downloads from [Nyaa.si](https://nyaa.si).
|
||||||
|
|
||||||
|
It is currently comprised of:
|
||||||
|
- `fetch`: intended to be run as a cron job, will download files through a
|
||||||
|
torrent daemon and link them in a [Jellyfin style](https://jellyfin.org/docs/general/server/media/shows) organization
|
||||||
|
- `helper`: provides a CLI for adding items to the tracking list which `fetch` employs
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- toml
|
||||||
|
- requests
|
||||||
|
- transmission-rpc (fetch only)
|
||||||
|
- inquirer (helper only)
|
||||||
91
nyaa-tools-old/batch.py
Normal file
91
nyaa-tools-old/batch.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import toml
|
||||||
|
import transmission_rpc
|
||||||
|
|
||||||
|
# https://franklingu.github.io/programming/2016/03/01/creating-daemon-process-python-example-explanation/
|
||||||
|
def daemonize():
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
# exit first parent
|
||||||
|
exit(0)
|
||||||
|
except OSError:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# decouple from parent environment
|
||||||
|
os.chdir("/")
|
||||||
|
os.setsid()
|
||||||
|
|
||||||
|
# do second fork
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
exit(0)
|
||||||
|
except OSError:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
config = toml.load("/etc/nyaa-tools/config.toml")
|
||||||
|
|
||||||
|
def jelly_path(name: str, season: int, episode=None, offset=0) -> str:
|
||||||
|
if episode is None:
|
||||||
|
return "{base}/{name}/Season {season:02}".format(
|
||||||
|
base=config["library"]["base_path"],
|
||||||
|
name=name,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return "{base}/{name}/Season {season:02}/Episode S{season:02}E{episode:02}.mkv".format(
|
||||||
|
base=config["library"]["base_path"],
|
||||||
|
name=name,
|
||||||
|
season=season,
|
||||||
|
episode=episode + offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="nyaa-batch"
|
||||||
|
)
|
||||||
|
parser.add_argument('-t', '--title')
|
||||||
|
parser.add_argument('-s', '--season')
|
||||||
|
parser.add_argument('-l', '--link')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
season = int(args.season)
|
||||||
|
except TypeError:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
client = transmission_rpc.Client(host=config["transmission"]["host"])
|
||||||
|
torrent = client.add_torrent(args.link, files_wanted=None, paused=True)
|
||||||
|
torrent = client.get_torrent(torrent.id) # Don't ask
|
||||||
|
files = torrent.get_files()
|
||||||
|
files.sort()
|
||||||
|
wanted = []
|
||||||
|
for file in files:
|
||||||
|
# Only get files in root
|
||||||
|
if file.name.count('/') < 2:
|
||||||
|
wanted.append((file.id, len(wanted) + 1))
|
||||||
|
|
||||||
|
client.change_torrent(torrent.id, files_wanted=map(lambda f: f[0], wanted))
|
||||||
|
client.start_torrent(torrent.id)
|
||||||
|
daemonize()
|
||||||
|
|
||||||
|
while len(wanted) > 0:
|
||||||
|
torrent = client.get_torrent(torrent.id)
|
||||||
|
get_files = torrent.get_files()
|
||||||
|
for want in wanted:
|
||||||
|
file = next(x for x in get_files if x.id == want[0])
|
||||||
|
if file.size == file.completed:
|
||||||
|
if not os.path.exists(jelly_path(args.title, season)):
|
||||||
|
os.makedirs(jelly_path(args.title, season))
|
||||||
|
os.link(
|
||||||
|
config["transmission"]["download_path"] + "/" + file.name,
|
||||||
|
jelly_path(args.title, season, want[1])
|
||||||
|
)
|
||||||
|
wanted.remove(want)
|
||||||
|
sleep(5)
|
||||||
126
nyaa-tools-old/fetch.py
Executable file
126
nyaa-tools-old/fetch.py
Executable file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from time import sleep
|
||||||
|
import requests
|
||||||
|
import toml
|
||||||
|
import logging
|
||||||
|
import xml.dom.minidom
|
||||||
|
import re
|
||||||
|
import transmission_rpc
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logging.basicConfig(format="%(name)s - %(levelname)s - %(message)s", level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
config = toml.load("/etc/nyaa-tools/config.toml")
|
||||||
|
series = toml.load("/etc/nyaa-tools/series.toml")
|
||||||
|
release_groups = toml.load("/etc/nyaa-tools/release-groups.toml")
|
||||||
|
|
||||||
|
|
||||||
|
def jelly_path(name: str, season: int, episode=None, offset=0) -> str:
|
||||||
|
if episode is None:
|
||||||
|
return "{base}/{name}/Season {season:02}".format(
|
||||||
|
base=config["library"]["base_path"],
|
||||||
|
name=name,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return "{base}/{name}/Season {season:02}/Episode S{season:02}E{episode:02}.mkv".format(
|
||||||
|
base=config["library"]["base_path"],
|
||||||
|
name=name,
|
||||||
|
season=season,
|
||||||
|
episode=episode + offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
logger.info("Searching for {}".format(" ".join(sys.argv[1:])))
|
||||||
|
nyaa_body = requests.get(
|
||||||
|
"https://nyaa.si/?page=rss&q={}".format(" ".join(sys.argv[1:]))
|
||||||
|
).text
|
||||||
|
else:
|
||||||
|
nyaa_body = requests.get("https://nyaa.si/?page=rss").text
|
||||||
|
items = xml.dom.minidom.parseString(nyaa_body).getElementsByTagName("item")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadItem:
|
||||||
|
torrent_id: int
|
||||||
|
name: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
downloading: list[DownloadItem] = []
|
||||||
|
client: transmission_rpc.Client = None
|
||||||
|
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
title: str = item.getElementsByTagName("title")[0].firstChild.data
|
||||||
|
link: str = item.getElementsByTagName("link")[0].firstChild.data
|
||||||
|
|
||||||
|
logger.debug("Checking {}".format(title))
|
||||||
|
|
||||||
|
for season in series["tracking"]:
|
||||||
|
regex = re.search(release_groups[season["release_group"]], title)
|
||||||
|
if regex is None:
|
||||||
|
continue
|
||||||
|
if season["name"] in regex.group("title"):
|
||||||
|
logger.debug("Title matched for {}".format(season["name"]))
|
||||||
|
# Extract chapter number
|
||||||
|
chapter = int(regex.group("episode"))
|
||||||
|
logger.debug("Chapter extracted: {}".format(chapter))
|
||||||
|
|
||||||
|
# Check if chapter already exists
|
||||||
|
path = jelly_path(
|
||||||
|
season["name"], season["season"], chapter, season.get("offset", 0)
|
||||||
|
)
|
||||||
|
if os.path.exists(path):
|
||||||
|
logger.debug(
|
||||||
|
'Chapter {} for "{}" already exists. Skipping'.format(
|
||||||
|
chapter, season["name"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sign in to Transmission
|
||||||
|
client = transmission_rpc.Client(host=config["transmission"]["host"])
|
||||||
|
torrent = client.add_torrent(link)
|
||||||
|
downloading.append(
|
||||||
|
DownloadItem(
|
||||||
|
torrent.id,
|
||||||
|
season["name"],
|
||||||
|
season["season"],
|
||||||
|
chapter,
|
||||||
|
season.get("offset", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Downloading {}".format(title))
|
||||||
|
|
||||||
|
while len(downloading) > 0:
|
||||||
|
for item in downloading:
|
||||||
|
torrent = client.get_torrent(item.torrent_id)
|
||||||
|
if torrent.left_until_done == 0:
|
||||||
|
if not os.path.exists(jelly_path(item.name, item.season)):
|
||||||
|
logger.warning(
|
||||||
|
'Season folder for "{}" did not exist, creating recursively'.format(
|
||||||
|
item.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
os.makedirs(jelly_path(item.name, item.season))
|
||||||
|
|
||||||
|
os.link(
|
||||||
|
config["transmission"]["download_path"]
|
||||||
|
+ "/"
|
||||||
|
+ torrent.get_files()[0].name,
|
||||||
|
jelly_path(item.name, item.season, item.episode, item.offset),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'Linked "{}" season {} chapter {}'.format(
|
||||||
|
item.name, item.season, item.episode + item.offset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
downloading.remove(item)
|
||||||
|
sleep(5)
|
||||||
124
nyaa-tools-old/helper.py
Executable file
124
nyaa-tools-old/helper.py
Executable file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import requests
|
||||||
|
import toml
|
||||||
|
import xml.dom.minidom
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
|
import inquirer
|
||||||
|
|
||||||
|
release_groups = toml.load("/etc/nyaa-tools/release-groups.toml")
|
||||||
|
release_group_names = release_groups.keys()
|
||||||
|
|
||||||
|
query = inquirer.text("Name of the anime")
|
||||||
|
nyaa_search = requests.get("https://nyaa.si/?page=rss&q={}".format(query)).text
|
||||||
|
items = xml.dom.minidom.parseString(nyaa_search).getElementsByTagName("item")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Episode:
|
||||||
|
title: str
|
||||||
|
link: str
|
||||||
|
release_group: str
|
||||||
|
|
||||||
|
|
||||||
|
def episode_info(ep: Episode, season: int, offset: int) -> str:
|
||||||
|
return (
|
||||||
|
"Title: {}\n".format(ep.title)
|
||||||
|
+ "Season: {}\n".format(season)
|
||||||
|
+ "Chapter offset: {}\n".format(offset)
|
||||||
|
+ "Release Group: {}".format(ep.release_group)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
matches: list[Episode] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
title: str = item.getElementsByTagName("title")[0].firstChild.data
|
||||||
|
link: str = item.getElementsByTagName("link")[0].firstChild.data
|
||||||
|
|
||||||
|
regex = re.search("\\[([^ ]+)\\].+", title)
|
||||||
|
if regex is not None:
|
||||||
|
matches.append(Episode(title, link, regex.group(1)))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
print("No matching entries")
|
||||||
|
exit(255)
|
||||||
|
|
||||||
|
known_matches = filter(lambda ep: ep.release_group in release_group_names, matches)
|
||||||
|
parsed_matches: list[Episode] = []
|
||||||
|
|
||||||
|
for match in known_matches:
|
||||||
|
rg_regex = re.search(release_groups[match.release_group], match.title)
|
||||||
|
if rg_regex is not None:
|
||||||
|
title = rg_regex.group(1)
|
||||||
|
parsed_matches.append(Episode(title, match.link, match.release_group))
|
||||||
|
|
||||||
|
if not parsed_matches:
|
||||||
|
print("No known release groups were found, however the following were identified:")
|
||||||
|
for match in set(map(lambda m: m.release_group, matches)):
|
||||||
|
print("-", match)
|
||||||
|
print(
|
||||||
|
"Please add one of them to the release-groups.toml or wait for a release from a known group"
|
||||||
|
)
|
||||||
|
exit(255)
|
||||||
|
|
||||||
|
choices = set(map(lambda m: m.release_group, parsed_matches))
|
||||||
|
if len(choices) > 1:
|
||||||
|
chosen_rg = inquirer.list_input(
|
||||||
|
"Choose a release group",
|
||||||
|
choices=sorted(set(map(lambda m: m.release_group, parsed_matches))),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
chosen_rg = choices.pop()
|
||||||
|
|
||||||
|
final_match = next(m for m in parsed_matches if m.release_group == chosen_rg)
|
||||||
|
season = 1
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
print(episode_info(final_match, season, offset))
|
||||||
|
ans = inquirer.list_input(
|
||||||
|
"The following entry will be added",
|
||||||
|
choices=["Confirm", "Edit", "Cancel"],
|
||||||
|
)
|
||||||
|
if ans == "Confirm":
|
||||||
|
break
|
||||||
|
elif ans == "Cancel":
|
||||||
|
exit(0)
|
||||||
|
while True:
|
||||||
|
print(episode_info(final_match, season, offset))
|
||||||
|
ans = inquirer.list_input(
|
||||||
|
"What do you want to edit", choices=["Title", "Season", "Offset", "Done"]
|
||||||
|
)
|
||||||
|
if ans == "Done":
|
||||||
|
break
|
||||||
|
if ans == "Title":
|
||||||
|
final_match.title = inquirer.text("New title", default=final_match.title)
|
||||||
|
elif ans == "Season":
|
||||||
|
season = int(inquirer.text(
|
||||||
|
"Season number", validate=lambda _, x: re.match("\\d+", x)
|
||||||
|
))
|
||||||
|
elif ans == "Offset":
|
||||||
|
offset = int(inquirer.text(
|
||||||
|
"Chapter offset", validate=lambda _, x: re.match("\\d+", x)
|
||||||
|
))
|
||||||
|
|
||||||
|
series = toml.load("/etc/nyaa-tools/series.toml")
|
||||||
|
if "tracking" not in series.keys():
|
||||||
|
series["tracking"] = []
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"name": final_match.title,
|
||||||
|
"season": season,
|
||||||
|
"release_group": final_match.release_group,
|
||||||
|
}
|
||||||
|
if offset != 0:
|
||||||
|
entry["offset"] = offset
|
||||||
|
series["tracking"].append(entry)
|
||||||
|
|
||||||
|
with open("/etc/nyaa-tools/series.toml", "w") as f:
|
||||||
|
toml.dump(series, f)
|
||||||
|
print(
|
||||||
|
"Done! Please run `nyaa-fetch '[{}] {}'` to download previous episodes".format(
|
||||||
|
final_match.release_group, final_match.title
|
||||||
|
)
|
||||||
|
)
|
||||||
253
nyaa-tools-old/web/server.py
Normal file
253
nyaa-tools-old/web/server.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
#!/bin/env python3
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import xml.dom.minidom
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import toml
|
||||||
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
make_response,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
release_groups = toml.load("/etc/nyaa-tools/release-groups.toml")
|
||||||
|
batch_groups = toml.load("/etc/nyaa-tools/batch-groups.toml")
|
||||||
|
|
||||||
|
DEV_ENV = bool(os.environ.get("DEV_ENV", default=False))
|
||||||
|
if not DEV_ENV:
|
||||||
|
PASSWORD = os.environ["PASSWORD"]
|
||||||
|
|
||||||
|
|
||||||
|
active_sessions = {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_token_valid(token):
|
||||||
|
return (
|
||||||
|
token and token in active_sessions and datetime.now() < active_sessions[token]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Creates a token and adds it to the "database"
|
||||||
|
def create_valid_token():
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
current_time = datetime.now()
|
||||||
|
expiration_time = current_time + timedelta(days=1)
|
||||||
|
|
||||||
|
active_sessions[token] = expiration_time
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def remove_token(token):
|
||||||
|
del active_sessions[token]
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
if not DEV_ENV:
|
||||||
|
if request.path == "/login":
|
||||||
|
return login()
|
||||||
|
else:
|
||||||
|
if not request.path.startswith("/static"):
|
||||||
|
token = request.cookies.get("session_token")
|
||||||
|
if not is_token_valid(token):
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchItem:
|
||||||
|
title: str
|
||||||
|
link: str
|
||||||
|
|
||||||
|
def search(query) -> list[SearchItem]:
|
||||||
|
nyaa_search = requests.get("https://nyaa.si/?page=rss&q={}".format(query)).text
|
||||||
|
items = xml.dom.minidom.parseString(nyaa_search).getElementsByTagName("item")
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
title: str = item.getElementsByTagName("title")[0].firstChild.data # pyright: ignore[reportOptionalMemberAccess,reportAttributeAccessIssue]
|
||||||
|
link: str = item.getElementsByTagName("link")[0].firstChild.data # pyright: ignore[reportOptionalMemberAccess,reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
result.append(SearchItem(title, link))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
password = request.form["password"]
|
||||||
|
|
||||||
|
if password == PASSWORD:
|
||||||
|
token = create_valid_token()
|
||||||
|
|
||||||
|
# Set token in a cookie
|
||||||
|
resp = make_response(redirect(url_for("index")))
|
||||||
|
resp.set_cookie("session_token", token, max_age=60 * 60 * 24)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
return "Invalid credentials. Try again."
|
||||||
|
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
# Get the session token
|
||||||
|
token = request.cookies.get("session_token")
|
||||||
|
|
||||||
|
remove_token(token)
|
||||||
|
|
||||||
|
# Clear the session and cookie
|
||||||
|
resp = make_response(redirect(url_for("login")))
|
||||||
|
resp.set_cookie("session_token", "", expires=0) # Remove cookie
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeriesInfo:
|
||||||
|
release_group: str
|
||||||
|
title: str
|
||||||
|
season: int | None
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the search page if q is empty, else it shows results
|
||||||
|
@app.route("/series", methods=["GET", "POST"])
|
||||||
|
def series():
|
||||||
|
if request.method == "GET":
|
||||||
|
query = request.args.get("q", default="", type=str)
|
||||||
|
|
||||||
|
if query == "":
|
||||||
|
return render_template("series/search.html")
|
||||||
|
|
||||||
|
results = search(query)
|
||||||
|
parsed_results = []
|
||||||
|
for item in results:
|
||||||
|
title = item.title
|
||||||
|
rg = re.search("\\[([^ ]+)\\].+", title)
|
||||||
|
if rg is None or rg.group(1) not in release_groups.keys():
|
||||||
|
continue
|
||||||
|
info = re.search(release_groups[rg.group(1)], title)
|
||||||
|
# TODO: Do not append duplicated entries
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
season = int(info.group("season"))
|
||||||
|
except IndexError:
|
||||||
|
season = None
|
||||||
|
parsed_results.append(
|
||||||
|
SeriesInfo(
|
||||||
|
release_group=rg.group(1),
|
||||||
|
title=info.group("title"),
|
||||||
|
season=season
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return render_template("series/results.html", results=parsed_results)
|
||||||
|
elif request.method == "POST":
|
||||||
|
release_group = request.form["release-group"]
|
||||||
|
title = request.form["title"]
|
||||||
|
season = request.form["season"]
|
||||||
|
if release_group and title and season:
|
||||||
|
try:
|
||||||
|
season = int(season)
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid season", 400
|
||||||
|
|
||||||
|
series = toml.load("/etc/nyaa-tools/series.toml")
|
||||||
|
if "tracking" not in series.keys():
|
||||||
|
series["tracking"] = []
|
||||||
|
|
||||||
|
# TODO: Do not allow duplicates
|
||||||
|
entry = {"name": title, "release_group": release_group, "season": season}
|
||||||
|
series["tracking"].append(entry)
|
||||||
|
with open("/etc/nyaa-tools/series.toml", "w") as f:
|
||||||
|
toml.dump(series, f)
|
||||||
|
return "OK", 200
|
||||||
|
else:
|
||||||
|
return "Missing parameters", 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchInfo:
|
||||||
|
release_group: str
|
||||||
|
title: str
|
||||||
|
season: int | None
|
||||||
|
episodes: int | None
|
||||||
|
link: str
|
||||||
|
|
||||||
|
@app.route("/batch", methods=["GET", "POST"])
|
||||||
|
def batch():
|
||||||
|
if request.method == "GET":
|
||||||
|
query = request.args.get("q", default="", type=str)
|
||||||
|
|
||||||
|
if query == "":
|
||||||
|
return render_template("batch/search.html")
|
||||||
|
|
||||||
|
results = search(query)
|
||||||
|
parsed_results = []
|
||||||
|
for item in results:
|
||||||
|
title = item.title
|
||||||
|
rg = re.search("\\[([^ ]+)\\].+", title)
|
||||||
|
if rg is None or rg.group(1) not in batch_groups.keys():
|
||||||
|
continue
|
||||||
|
info = re.search(batch_groups[rg.group(1)], title)
|
||||||
|
# TODO: Do not append duplicated entries
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
season = int(info.group("season"))
|
||||||
|
except IndexError:
|
||||||
|
season = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
episodes = int(info.group("last")) - int(info.group("first")) + 1
|
||||||
|
except IndexError:
|
||||||
|
#TODO: Get episodes from file list (requires lxml & cssselector)
|
||||||
|
episodes = None
|
||||||
|
|
||||||
|
parsed_results.append(
|
||||||
|
BatchInfo(
|
||||||
|
release_group=rg.group(1),
|
||||||
|
title=info.group("title"),
|
||||||
|
season=season,
|
||||||
|
episodes=episodes,
|
||||||
|
link=item.link
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return render_template("batch/results.html", results=parsed_results)
|
||||||
|
elif request.method == "POST":
|
||||||
|
title = request.form["title"]
|
||||||
|
season = request.form["season"]
|
||||||
|
link = request.form["link"]
|
||||||
|
if title and season and link:
|
||||||
|
try:
|
||||||
|
season = int(season)
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid season", 400
|
||||||
|
|
||||||
|
ret = os.system(" ".join(["nyaa-batch", "-t", "'{}'".format(title), "-s", str(season), "-l", "'{}'".format(link)]))
|
||||||
|
if ret == 0:
|
||||||
|
return "OK", 200
|
||||||
|
else:
|
||||||
|
return "Error", 500
|
||||||
|
else:
|
||||||
|
return "Missing parameters", 400
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=DEV_ENV)
|
||||||
110
nyaa-tools-old/web/static/style.css
Normal file
110
nyaa-tools-old/web/static/style.css
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full_form {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button_form {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
background-color: #333;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #359039;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #f5f5f5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #f5f5f5;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 60vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
28
nyaa-tools-old/web/templates/batch/results.html
Normal file
28
nyaa-tools-old/web/templates/batch/results.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Search') }}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Search Results</h1>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Release group</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Season</th>
|
||||||
|
<th>Episodes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
{% for item in results %}
|
||||||
|
<tr><form action="/batch" method="post">
|
||||||
|
<td>{{ item.release_group }}</td>
|
||||||
|
<td><input type="text" name="title" value="{{ item.title }}" required /></td>
|
||||||
|
<td><input type="number" name="season" {% if item.season %}value="{{ item.season }}"{% endif %} min="1" max="99" required /></td>
|
||||||
|
<td>{% if item.episodes %}{{item.episodes}}{% else %}?{% endif %}</td>
|
||||||
|
<input type="hidden" name="link" value="{{ item.link }}">
|
||||||
|
<td><input type="submit" value="Download" class="btn-link" /></td>
|
||||||
|
</form></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
6
nyaa-tools-old/web/templates/batch/search.html
Normal file
6
nyaa-tools-old/web/templates/batch/search.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Search') }}
|
||||||
|
|
||||||
|
{{ macros.search('batch', "add 'batch' to the search terms to find batches") }}
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
12
nyaa-tools-old/web/templates/index.html
Normal file
12
nyaa-tools-old/web/templates/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Hub')}}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<a href="/series">Add series</a>
|
||||||
|
<a href="/batch">Download batch</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
13
nyaa-tools-old/web/templates/login.html
Normal file
13
nyaa-tools-old/web/templates/login.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Login')}}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form class="full_form" method="POST">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required><br><br>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
29
nyaa-tools-old/web/templates/macros.html
Normal file
29
nyaa-tools-old/web/templates/macros.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% macro header(title) %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/full_page.css">
|
||||||
|
</head>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro footer() %}
|
||||||
|
</html>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro search(path, tip) %}
|
||||||
|
<body>
|
||||||
|
<h1>Search</h1>
|
||||||
|
<form class="full_form" action="/{{ path }}" method="GET">
|
||||||
|
<label for="query">Search for:</label>
|
||||||
|
<input type="text" id="query" name="q" required /><br>
|
||||||
|
{%- if tip %}<i>Tip: {{ tip }}</i><br>{% endif -%}
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</body>
|
||||||
|
{% endmacro %}
|
||||||
25
nyaa-tools-old/web/templates/series/results.html
Normal file
25
nyaa-tools-old/web/templates/series/results.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Search') }}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Search Results</h1>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Release group</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Season</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
{% for item in results %}
|
||||||
|
<tr><form action="/series" method="post">
|
||||||
|
<td>{{item.release_group}}<input type="hidden" name="release-group" value="{{item.release_group}}" readonly required/></td>
|
||||||
|
<td><input type="text" name="title" value="{{item.title}}" required /></td>
|
||||||
|
<td><input type="number" name="season" {% if item.season %}value="{{item.season}}"{% endif %} min="1" max="99" required /></td>
|
||||||
|
<td><input type="submit" value="Add" class="btn-link" /></td>
|
||||||
|
</form></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
6
nyaa-tools-old/web/templates/series/search.html
Normal file
6
nyaa-tools-old/web/templates/series/search.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% import 'macros.html' as macros %}
|
||||||
|
{{ macros.header('Search') }}
|
||||||
|
|
||||||
|
{{ macros.search('series', "if a series' season is not autodetected you may need to remove it from the title") }}
|
||||||
|
|
||||||
|
{{ macros.footer() }}
|
||||||
Loading…
Add table
Reference in a new issue