Batch mark whole series as watched or unwatched on the aniworld.to, bs.to family, and s.to family streaming sites.
Each reachable host gets its own worker — all hosts log in and run at once — and each worker discovers every season of its host's series URLs, strictly one series at a time, invoking the site's native "mark all episodes in this season" control.
When marking a series as WATCHED on the aniworld or s.to family, watchmaker also subscribes to the series first (if it is not already subscribed). The subscribe controls are toggles, so the request is sent once and never resent blind; the series page read after marking decides, and a series that did not end up subscribed — or whose page has no subscribe control — is reported as failed. The bs.to family has no subscribe control, so this step is skipped there.
aniworld.to,aniworld.cc,186.2.175.111bs.cine.to,burningseries.ac,burningseries.cx(bs.tois a dead primary and intentionally not matched)serienstream.to,serienstream.cx,186.2.175.5(s.tois a dead primary and intentionally not matched)
- Python 3.11+ — developed and tested on 3.14.
requires-pythoninpyproject.tomlenforces 3.11, so pip will refuse anything older. The code itself uses nothing newer than 3.10 features (zip(strict=True), PEP 604X | Noneannotations evaluated at runtime), so 3.10 would very likely work — it is simply not tested. - Dependencies:
httpx,lxml,h2,python-dotenv
lxml and h2 are the speed-relevant ones: pages parse ~4-6x quicker than with
BeautifulSoup, and HTTP/2 lets one connection carry many requests.
-
Clone the repository and install dependencies:
git clone https://github.com/Nawid3333/watchmaker.git cd watchmaker python -m venv .venv source .venv/bin/activate # Linux / macOS .venv\Scripts\Activate.ps1 # Windows (PowerShell) pip install -r requirements.txt
-
Copy
.env.exampleto.envand fill in your credentials:cp .env.example .env
-
Add series URLs to the default batch file (
series_urls.txt), one per line. Lines starting with#are ignored. Two URLs pointing at the same series (for example/serie/xand/serie/x/staffel-3) mark the same thing, so only the first is used: a season or episode in a URL is ignored and the whole series is marked, which the preview points out for every such URL. Batch files are read as UTF-8 (a BOM is fine); a file in another encoding is refused with a message rather than guessed at. To keep some entries around permanently instead of clearing them with option 7, see The batch file has two parts below.
Building a wheel puts a watchmaker command on your PATH:
pip install build
python -m build
pip install dist/watchmaker-*-py3-none-any.whlTwo things are worth knowing before you do.
Give each program its own virtual environment. This project and its siblings
ship their code as the top-level modules main and config. Install two of them
into the same environment and the second overwrites the first — the command still
exists, but it silently runs the other program. pipx creates an isolated
environment per application and avoids this entirely:
pipx install .Tell it where to keep your files. Once installed, the package lives inside
site-packages, which is no place to keep a .env you have to edit by hand.
Point WATCHMAKER_HOME at a folder you own, and .env , the batch file, and the data/ and logs/ folders all move there:
export WATCHMAKER_HOME=~/watchmaker # Linux / macOS
$env:WATCHMAKER_HOME = "$HOME\watchmaker" # Windows (PowerShell)
mkdir -p ~/watchmaker
cp .env.example ~/watchmaker/.envIf you skip that copy, the first run writes the template there for you and
says where it put it -- so an installed copy never leaves you hunting for a
file inside site-packages.
WATCHMAKER_HOME has to be a real environment variable. It cannot be set inside .env,
because it is what tells the program where to find that file in the first place.
Left unset it resolves to the checkout, which is why running from a clone needs
no configuration at all.
Run the interactive menu:
python main.pyThe program starts with the default batch file (series_urls.txt) already loaded. If the file is empty, the menu is still shown so you can add a URL or switch batch files with option 5.
Each host is pinged once; unreachable hosts are skipped, and reachable family mirrors are used automatically. Raw IP addresses such as 186.2.175.5 are contacted over HTTP, all other hosts over HTTPS. Reachable hosts are resolved on startup and URLs in the batch file are rewritten to the first reachable mirror of each site family. The same refresh happens after retrying failed URLs, changing the batch, importing URLs, or clearing temporary entries.
- Mark as WATCHED
- Mark as UNWATCHED
- Export URLs to scraper lists
- Import URLs from scraper lists
- Add or run a link / change batch
- Retry failed URLs
- Clear temporary entries
- Exit
Before marking, a preview of every series, season, and current episode count is shown. For the aniworld and s.to families the preview also shows the current subscription (Sub) and watchlist (WL) status per series, plus a ⚡ badge when a subscription change is pending. Confirm with y to proceed or n to cancel.
The preview is grouped rather than interleaved: ALREADY AT TARGET, WILL CHANGE, and COULD NOT READ are printed as separate blocks, so you can see at a glance what a run is actually going to do. Series already at the target state are reported as-is and are not touched again, so a re-run only does work where something has to change.
Marking runs every host at once. Each site keeps its own worker, session and strict one-series-at-a-time order, so no site ever sees two requests from a run — but finishing one host no longer leaves the others idle, which is what used to make switching from one domain to the next feel like a pause.
series_urls.txt has two ways to mark a URL permanent, usable together:
https://serienstream.to/serie/some-show
https://burningseries.ac/serie/Some-Show
-https://serienstream.to/serie/a-quick-one-off-pin
# ===== KEEP BELOW (never cleared by option 7) =====
https://serienstream.to/serie/a-show-you-always-track
- A block. Everything from the marker line down is permanent. Good for a group of shows you always keep, like ongoing/trash-TV watchlists.
- A single line. A
-directly before one URL, with no blank line splitting them, pins just that entry — wherever it sits in the file, no need to move it into the block.
Everything else — no marker below it, no - in front of it — is temporary.
- Option 7 clears temporary entries — URLs only. Your own comments and blank lines are left alone, and it shows you exactly what will go before asking.
- Adding URLs (option 5, or importing with option 4) inserts them above the marker, untagged, so new series always land in the working list.
- Pasting a single URL with option 5 and choosing to overwrite replaces the temporary entries only; the keep block and any
--tagged line are left alone. The entries it would remove are listed first, and nothing changes without ay. - Permanent does not mean skipped: both kinds of permanent entries are still marked by options 1 and 2 like any other. Neither one controls anything but what option 7 removes.
The marker is a comment, so a batch file using neither mechanism still works exactly as before — everything in it simply counts as temporary. It's matched loosely (# KEEP …, any spacing, casing, or number of =), because it is meant to be edited by hand.
A season counts as successful only when the episode page, re-read after the request, actually shows the target state:
- Every mark is verified by re-fetching the season page, whether a request was sent or the season was already at the target state. These sites answer
HTTP 200even when nothing changed, so the response status alone proves nothing. - A mark that did not stick is sent once more and read back again before the season counts as failed, so a lost mark is recovered and a failure is a real one. A season that needed it says
stuck on the second mark; one that failed both times says(marked twice). There is never a third mark, and a season that needed no mark, or whose only miss is a listed episode 0, gets no second one. - A season page where no episode rows can be parsed is reported as failed (
no episodes found), never as a silent success — an unreadable page means the result cannot be verified. - If verification itself fails (network error, error page), the season is reported as unverified and lands in the retry list. So is a read-back page that lists no episodes, lists fewer episodes than before marking, or shows no logged-in session: each of those would read as "0 watched", which is exactly what an unwatch run aims for. Re-running is safe: a season already at the target state issues no request.
- Every page whose counts are used — in the preview and when marking — has to show the logged-in session. A page that does not is never counted: watchmaker logs in again and reads it once more, and fails the season if it still shows no session.
- The failures of a run are listed in full under FAILED in the run summary, one reason per season, and next to each series as it finishes.
✓is action-aware: a fully unwatched series is a success at 0 watched episodes.- If a session expires mid-batch — a missing control, a logged-out answer from bs.to's mark link, or a
401/419to a mark — watchmaker re-authenticates once and retries that season before giving up. - Series whose site has no reachable mirror are reported as not attempted and recorded for option 6. If a run is interrupted with Ctrl+C, the series it had not finished are recorded too.
- A retired or mistyped slug is answered by these sites with the catalogue page at
HTTP 200. Such a page is rejected by name (Alle Serien,Andere Serien, ...) instead of being marked as if it were a real series. - Some seasons carry an episode 0 placeholder that the site accepts a mark for and then never shows as watched. Seasons listed in a scraper's
data/.ignored_seasons.json(found next to theSERIES_URLS_EXPORTSfile for that family) have episode 0 left out of the count, exactly as the scraper does, so it never decides✓or✗. It is still marked and re-checked every run, and every episode 0 met is shown in the CLI: in the preview, as· E0: S1 placeholderon the result line, and in full in theEPISODE 0block of the run summary. That block flags, with the fix to make:- an unlisted episode 0 that did not stick (the season fails; the entry to add is printed),
- a listed episode 0 that now stays watched (the entry may no longer be needed),
- a stale entry, whose season no longer has an episode 0.
- Before the preview, each family's ignore list is shown with the file it came from and how many seasons it holds. A list that is missing, unreadable or malformed ignores nothing and is flagged
⚠there, again right aboveproceed with marking?, and in the run summary. The BS.to scraper keeps no such list, so for bs.to its absence is only noted. - s.to accepts 30 POSTs (season marks and subscribes together) per minute per account, then refuses every POST until the minute is up. watchmaker spaces its s.to POSTs to stay under that, and waits out a whole window on a
429rather than retrying into it.
python -m unittest discover -s testsCovers URL classification, batch-file rewriting, season discovery, episode counting, title extraction, and the mark/verify logic. No extra dependencies.
.github/workflows/site-check.yml runs tests/site_check.py on the 3rd of
every month. It runs watchmaker's own host probe and page readers against all
three live sites. If one of them would fail, it opens an issue labelled
site-check, and the first passing run closes it again. A site that blocks
GitHub's runners is reported as unreachable, not as a change. Run it yourself
any time:
python tests/site_check.pyWithout credentials it checks each login form and one public series and season
page per site. With a site's credentials (read from your .env locally, or
set as repository secrets for the workflow) it also logs in to that site and
looks for the controls a mark would use, through a worker that has no way to
send anything. It never changes anything, and its report holds no account
details.
While the program is running, select 5 to:
- Paste a single URL → you are asked whether to add the URL to the batch's temporary entries (
a), overwrite them with it (o, offered when there are any), or run it once (r), or cancel (c). Adding or overwriting keeps the keep block and any--tagged line. Adding a series that is already in the batch, on any mirror or season, changes nothing. - Run it once asks whether to mark the URL watched (
w) or unwatched (u) and runs it straight away, with the same preview, confirmation and verification as a batch run. It is written to no batch file and the active batch stays as it was; only a failure is recorded, for option 6, like any other. - Enter a file path → switches the current batch to that file (quotes around the path are fine). Only a readable UTF-8 file is accepted.
- Type
0to go back to the menu.
Every prompt takes only the answers it lists — y/n in either case, or the letters and numbers shown. Enter alone is never an answer, and anything else is asked again with what is allowed. End of input, or five unusable answers in a row, gives the answer that changes nothing.
Select 4 to pull URLs from the scraper series_urls.txt files defined in config.py (SERIES_URLS_EXPORTS) and append any new URLs to the current batch file. The import preview shows which URLs will be added per family and skips anything already present in the batch.
Change the default batch file permanently by editing DEFAULT_BATCH_FILE_PATH in config.py:
DEFAULT_BATCH_FILE_PATH = "series_urls.txt" # relative to the project folder
DEFAULT_BATCH_FILE_PATH = r"C:\Users\me\urls.txt" # absolute pathFor a one-off switch without editing any file, use option 5 while the program is running instead.
See config.py for credentials, supported domains, export/import targets, and the default batch file path.
WATCHMAKER_HOME decides where .env, the batch file, data/ and logs/ live.
Unset, that is this checkout. Set it when you install the package, so those do
not land in site-packages. It must be a real environment variable — it cannot go
in .env, because it is what locates that file.
Credentials are loaded from a .env file next to config.py (see .env.example):
ANIWORLD_EMAIL=...
ANIWORLD_PASSWORD=...
BS_USERNAME=...
BS_PASSWORD=...
STO_EMAIL=...
STO_PASSWORD=...
Menu option 3 exports URLs to each scraper's series_urls.txt, and option 4 reads
them back. The defaults assume the three scrapers sit next to this project, which
is the normal layout, and are derived from that — nothing is hardcoded to one
machine. Point them anywhere with:
WATCHMAKER_ANIWORLD_URLS=/path/to/Aniworld.to HTTPX scraper/series_urls.txt
WATCHMAKER_BS_URLS=/path/to/BS.to HTTPX scraper/series_urls.txt
WATCHMAKER_STO_URLS=/path/to/S.to HTTPX scraper/series_urls.txt
├── .env.example # Template for your credentials
├── .gitignore
├── LICENSE # GNU GPL v3.0
├── README.md # This file
├── config.py # Domains, credentials, export targets, paths
├── main.py # Entry point & interactive menu
├── requirements.txt # Python dependencies
└── tests/
├── site_check.py # Monthly live-site check (see Tests)
└── test_watchmaker.py # Unit tests
Directories created at runtime (data/, logs/), your .env, and your
series_urls.txt batch file are not part of the repository.
data/.failed_urls.json— URLs that failed, so they can be retried. Each entry records which action (WATCHED/UNWATCHED) it failed under, so a success in one action never silently erases a failure recorded under the other for the same URL. Only series actually attempted in a run are reconciled, so failures recorded by an earlier run against a different batch are never silently dropped. Entries are matched by series, not by URL, so a failure on one mirror is cleared by a success on another. Option 6 shows which action each failure came from and retries one action's failures at a time: it writes them todata/retry_batch.txtunder a first line naming that action, and the menu refuses the other action on that batch.logs/watchmaker.log— detailed debug log.
Nawid Salehie
GNU General Public License v3.0 — see LICENSE for details.