first commit

This commit is contained in:
lily 2026-09-13 14:22:34 -04:00
commit 233b64a51d
14 changed files with 936 additions and 0 deletions

100
grab.py Normal file
View 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
View 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
View 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
View 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
View 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
)
)

View 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)

View 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;
}

View 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() }}

View 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() }}

View 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() }}

View 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() }}

View 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 %}

View 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() }}

View 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() }}