Compare commits

5 Commits

23 changed files with 151 additions and 100 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/test.xml /test.xml
/uv.lock /uv.lock
/main.py

View File

@@ -1,18 +1,24 @@
[build-system] [build-system]
requires = ["hatchling>=1.27.0"] requires = ["hatchling>=1.29.0"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "watfag" name = "watfag"
dynamic = ["version"] dynamic = ["version"]
description = "Searches torrents with Jackett and scores them with WATFAG" description = "Searches torrents with Jackett and scores them with WATFAG"
requires-python = ">=3.14" requires-python = ">=3.12"
dependencies = [ dependencies = [
"feedparser>=6.0.12", "feedparser>=6.0.12",
"httpx>=0.28.1", "httpx>=0.28.1",
"regex>=2026.2.28", "regex>=2026.2.28",
] ]
[dependency-groups]
dev = [
"hatch>=1.16.5",
"hatchling>=1.29.0",
]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = [ packages = [
"src/watfag", "src/watfag",

View File

@@ -1 +1 @@
__version__ = "1.0.0" __version__ = "1.0.4"

View File

@@ -2,27 +2,62 @@ import importlib
from pathlib import Path from pathlib import Path
from pkgutil import iter_modules from pkgutil import iter_modules
from typing import Optional, Type from typing import Optional, Type
from xml.etree.ElementTree import Element
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
from parsers.generic.watfag import WATFAG, SeedStatus from watfag.parsers.generic.watfag import *
from watfag.parsers.generic.watfag import WATFAG
from watfag.trackers import TrackerInfo
class Release: class Release:
def __init__( def __init__(
self, self,
unparsed_text, xml_result: Element,
dl_link, tracker_info: Optional[TrackerInfo] = None
**kwargs
): ):
self.original_text: str = unparsed_text self.xml: Element = xml_result
self.tracker_info: Optional[TrackerInfo] = tracker_info
self.original_text: str = ''
self.metadata_text: Optional[str] = '' self.metadata_text: Optional[str] = ''
self.dl_link: str = dl_link self.dl_link: str = ''
self.view_link: str = kwargs.get('view_link', dl_link) self.view_link: str = ''
self.size: int = kwargs.get('size', 0) self.size: int = 0
self.seeders: int = kwargs.get('seeders', 0) self.seeders: int = 0
self.tracker: str = ''
self.tracker_abbr: str = ''
self.seed_status: Optional[SeedStatus] = None self.seed_status: Optional[SeedStatus] = None
self.parser_results: dict[str, bool] = {} # Stores which parsers have been run and their results. self.parser_results: dict[str, bool] = {} # Stores which parsers have been run and their results.
self.title: Optional[str] = None
self.group: Optional[Group] = None
self.group_name: Optional[str] = None
self.quality: Optional[Resolution] = None
self.source: Optional[Source] = None
self.streaming: Optional[StreamingService] = None
self.video_codec: Optional[VideoCodec] = None
self.audio_codec: Optional[AudioCodec] = None
self.audio_layout: Optional[AudioLayout] = None
self.dynamic_range: Optional[DynamicRange] = None
self.repack: Optional[Repack] = None
self.multi: Optional[Multi] = None
self._parse_xml()
def _parse_xml(self):
# Get the torznab attributes
attrs: dict[str, list[str]] = {}
for attr in self.xml.findall('torznab:attr', namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}):
attrs[attr.get('name')] = attrs.get(attr.get('name'), []) + [attr.get('value')]
self.original_text = self.xml.find('title').text
self.dl_link = self.xml.find('link').text
self.size = int(self.xml.find('size').text)
self.seeders = int(attrs.get('seeders')[0])
self.view_link = self.xml.find('comments').text
self.tracker = self.xml.find('jackettindexer').text
self.tracker_abbr = self.tracker_info.get_tracker_info(self.tracker).get('Abbreviation') if self.tracker_info else self.tracker
def __lt__(self, other): def __lt__(self, other):
return self.watfag < other.watfag return self.watfag < other.watfag
@@ -63,6 +98,7 @@ class GenericParser:
class ParserManager: class ParserManager:
"""Manages and runs parsers on releases.""" """Manages and runs parsers on releases."""
def __init__(self): def __init__(self):
self.parsers: list[Type[DataParser]] = [] self.parsers: list[Type[DataParser]] = []
self.collect_parsers() self.collect_parsers()

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
from parsers.generic.watfag import AudioCodec, AudioLayout from watfag.parsers.generic.watfag import AudioCodec, AudioLayout
patterns = [ patterns = [
re.compile( re.compile(

View File

@@ -1,11 +1,12 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.watfag import Group from watfag.parsers.generic.watfag import Group
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
patterns = [ patterns = [
re.compile(r"(?:-| - )(?P<group>[a-zA-Z0-9 &]*)\)?$", re.UNICODE), re.compile(r"(?:-| - )(?P<group>[a-zA-Z0-9 &]*)\)?$", re.UNICODE),
re.compile(r"\[(?P<group>[a-zA-Z0-9 &]*)\]?$", re.UNICODE),
re.compile(r"(?: )\[?(?P<group>[a-zA-Z0-9]*?)]?\)?$", re.UNICODE) re.compile(r"(?: )\[?(?P<group>[a-zA-Z0-9]*?)]?\)?$", re.UNICODE)
] ]

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import DynamicRange from watfag.parsers.generic.watfag import DynamicRange
checks = { checks = {
re.compile(r"hybrid|do?vi? ?hdr(?:10)?[\+p]?", re.IGNORECASE): DynamicRange.HYBRID, re.compile(r"hybrid|do?vi? ?hdr(?:10)?[\+p]?", re.IGNORECASE): DynamicRange.HYBRID,

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import Multi from watfag.parsers.generic.watfag import Multi
checks = { checks = {
re.compile(r"multi", re.IGNORECASE): Multi.MULTI re.compile(r"multi", re.IGNORECASE): Multi.MULTI

View File

@@ -3,7 +3,7 @@ from typing import Optional, TYPE_CHECKING
from regex import Pattern from regex import Pattern
if TYPE_CHECKING: if TYPE_CHECKING:
from parsers.generic import WATFAG, Release from watfag.parsers.generic import WATFAG, Release
class DataParser: class DataParser:

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import Repack from watfag.parsers.generic.watfag import Repack
checks = { checks = {
re.compile(r"repack", re.IGNORECASE): Repack.REPACK, re.compile(r"repack", re.IGNORECASE): Repack.REPACK,

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import Resolution from watfag.parsers.generic.watfag import Resolution
checks = { checks = {
re.compile(r"2160p", re.IGNORECASE): Resolution.UHD, re.compile(r"2160p", re.IGNORECASE): Resolution.UHD,

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import SeedStatus from watfag.parsers.generic.watfag import SeedStatus
class SeederParser(CheckParser, GenericParser): class SeederParser(CheckParser, GenericParser):
def parse(self) -> bool: def parse(self) -> bool:

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import Source from watfag.parsers.generic.watfag import Source
checks = { checks = {
re.compile(r"remux", re.IGNORECASE): Source.REMUX, re.compile(r"remux", re.IGNORECASE): Source.REMUX,

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import StreamingService from watfag.parsers.generic.watfag import StreamingService
checks = { checks = {
re.compile(r"ATVP"): StreamingService.ATVP, re.compile(r"ATVP"): StreamingService.ATVP,

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic import GenericParser from watfag.parsers.generic import GenericParser
from parsers.generic.parsers import CheckParser from watfag.parsers.generic.parsers import CheckParser
from parsers.generic.watfag import VideoCodec from watfag.parsers.generic.watfag import VideoCodec
checks = { checks = {
re.compile(r"avc|[hx][\. -]?264", re.IGNORECASE): VideoCodec.AVC, re.compile(r"avc|[hx][\. -]?264", re.IGNORECASE): VideoCodec.AVC,

View File

@@ -189,6 +189,7 @@ class Group(WATFAG):
""" """
FLUX = "FLUX", 10 # Very good WEB-DL releases and fast FLUX = "FLUX", 10 # Very good WEB-DL releases and fast
HONE = "HONE", 10 # High quality re-encodes HONE = "HONE", 10 # High quality re-encodes
TAOE = "TAoE", 10 # High quality re-encodes
PHOCIS = "PHOCiS", 8 # Same as FLUX PHOCIS = "PHOCiS", 8 # Same as FLUX
LEGION = "LEGi0N", 8 # Same as FLUX LEGION = "LEGi0N", 8 # Same as FLUX
AOC = "AOC", 1 # Often low quality CAM releases. While fast, not worth it for most movies. AOC = "AOC", 1 # Often low quality CAM releases. While fast, not worth it for most movies.

View File

@@ -2,33 +2,28 @@ import importlib
from pathlib import Path from pathlib import Path
from pkgutil import iter_modules from pkgutil import iter_modules
from typing import Optional from typing import Optional
from xml.etree.ElementTree import Element
from parsers.generic import Release, ParserManager from watfag.parsers.generic import Release, ParserManager
from parsers.generic.watfag import * from watfag.trackers import TrackerInfo
class MovieRelease(Release): class MovieRelease(Release):
"""Holds info representing a release of a movie.""" """Holds info representing a release of a movie."""
def __init__(self, unparsed_text, dl_link, **kwargs):
super().__init__(unparsed_text, dl_link, **kwargs) def __init__(
self.title: str = "" self,
xml_result: Element,
tracker_info: Optional[TrackerInfo] = None
):
super().__init__(xml_result, tracker_info)
self.year: int = 0 self.year: int = 0
self.edition: Optional[str] = None self.edition: Optional[str] = None
self.group: Optional[Group] = None
self.group_name: Optional[str] = None
self.quality: Optional[Resolution] = None
self.source: Optional[Source] = None
self.streaming: Optional[StreamingService] = None
self.video_codec: Optional[VideoCodec] = None
self.audio_codec: Optional[AudioCodec] = None
self.audio_layout: Optional[AudioLayout] = None
self.dynamic_range: Optional[DynamicRange] = None
self.repack: Optional[Repack] = None
self.multi: Optional[Multi] = None
def __str__(self): def __str__(self):
parts = [f"{self.title} ({self.year})" + (f" [{self.edition}]" if self.edition else "")] parts = [f"{self.title} ({self.year})" + (f" [{self.edition}]" if self.edition else "")]
for attr in ['quality', 'video_codec', 'audio_codec', 'audio_layout', 'dynamic_range', 'repack', 'multi', 'source']: for attr in ['quality', 'video_codec', 'audio_codec', 'audio_layout', 'dynamic_range', 'repack', 'multi',
'source']:
value = getattr(self, attr) value = getattr(self, attr)
parts.append(f"{attr.capitalize()}: {value if value else 'Unknown'}") parts.append(f"{attr.capitalize()}: {value if value else 'Unknown'}")
if self.streaming: if self.streaming:
@@ -39,12 +34,14 @@ class MovieRelease(Release):
parts.append(f"WATFAG: {self.watfag:.2f}") parts.append(f"WATFAG: {self.watfag:.2f}")
return " | ".join(parts) return " | ".join(parts)
class MovieParser: class MovieParser:
""" """
This class can be inherited by any parser that is specific to movies. This class can be inherited by any parser that is specific to movies.
It allows dynamic importing of parser classes and provides a method to run all parsers on a given movie release. It allows dynamic importing of parser classes and provides a method to run all parsers on a given movie release.
""" """
class MovieParserManager(ParserManager): class MovieParserManager(ParserManager):
"""Parses movie releases.""" """Parses movie releases."""

View File

@@ -1,8 +1,8 @@
import regex as re import regex as re
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
from parsers.movie import MovieParser, MovieRelease from watfag.parsers.movie import MovieParser, MovieRelease
from parsers.movie.title_year import edition_regex from watfag.parsers.movie.title_year import edition_regex
class EditionParser(DataParser, MovieParser): class EditionParser(DataParser, MovieParser):

View File

@@ -1,7 +1,7 @@
import regex as re import regex as re
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
from parsers.movie import MovieParser, MovieRelease from watfag.parsers.movie import MovieParser, MovieRelease
# Shamelessly stolen from Radarr: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs # Shamelessly stolen from Radarr: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs
edition_regex = r"\(?\b(?P<edition>(((Recut.|Extended.|Ultimate.)?(Director.?s|Collector.?s|Theatrical|Ultimate|Extended|Despecialized|(Special|Rouge|Final|Assembly|Imperial|Diamond|Signature|Hunter|Rekall)(?=(.(Cut|Edition|Version)))|\d{2,3}(th)?.Anniversary)(?:.(Cut|Edition|Version))?(.(Extended|Uncensored|Remastered|Unrated|Uncut|Open.?Matte|IMAX|Fan.?Edit))?|((Uncensored|Remastered|Unrated|Uncut|Open?.Matte|IMAX|Fan.?Edit|Restored|((2|3|4)in1))))))\b\)?" edition_regex = r"\(?\b(?P<edition>(((Recut.|Extended.|Ultimate.)?(Director.?s|Collector.?s|Theatrical|Ultimate|Extended|Despecialized|(Special|Rouge|Final|Assembly|Imperial|Diamond|Signature|Hunter|Rekall)(?=(.(Cut|Edition|Version)))|\d{2,3}(th)?.Anniversary)(?:.(Cut|Edition|Version))?(.(Extended|Uncensored|Remastered|Unrated|Uncut|Open.?Matte|IMAX|Fan.?Edit))?|((Uncensored|Remastered|Unrated|Uncut|Open?.Matte|IMAX|Fan.?Edit|Restored|((2|3|4)in1))))))\b\)?"

View File

@@ -2,31 +2,24 @@ import importlib
from pathlib import Path from pathlib import Path
from pkgutil import iter_modules from pkgutil import iter_modules
from typing import Optional from typing import Optional
from xml.etree.ElementTree import Element
from parsers.generic import Release, ParserManager from watfag.parsers.generic import Release, ParserManager
from parsers.generic.watfag import * from watfag.trackers import TrackerInfo
class TVBoxSetRelease(Release): class TVBoxSetRelease(Release):
"""Holds info representing a release of a TV box set.""" """Holds info representing a release of a TV box set."""
def __init__(self, unparsed_text, dl_link, **kwargs): def __init__(
super().__init__(unparsed_text, dl_link, **kwargs) self,
self.show_title: str = "" xml_result: Element,
tracker_info: Optional[TrackerInfo] = None
):
super().__init__(xml_result, tracker_info)
self.seasons: Optional[str] = None self.seasons: Optional[str] = None
self.group: Optional[Group] = None
self.group_name: Optional[str] = None
self.quality: Optional[Resolution] = None
self.source: Optional[Source] = None
self.streaming: Optional[StreamingService] = None
self.video_codec: Optional[VideoCodec] = None
self.audio_codec: Optional[AudioCodec] = None
self.audio_layout: Optional[AudioLayout] = None
self.dynamic_range: Optional[DynamicRange] = None
self.repack: Optional[Repack] = None
self.multi: Optional[Multi] = None
def __str__(self): def __str__(self):
parts = [f"{self.show_title} (Seasons: {self.seasons})"] parts = [f"{self.title} (Seasons: {self.seasons})"]
for attr in ['quality', 'video_codec', 'audio_codec', 'audio_layout', 'dynamic_range', 'repack', 'multi', 'source']: for attr in ['quality', 'video_codec', 'audio_codec', 'audio_layout', 'dynamic_range', 'repack', 'multi', 'source']:
value = getattr(self, attr) value = getattr(self, attr)
parts.append(f"{attr.capitalize()}: {value if value else 'Unknown'}") parts.append(f"{attr.capitalize()}: {value if value else 'Unknown'}")
@@ -46,7 +39,6 @@ class TVBoxSetParser:
class TVBoxSetParserManager(ParserManager): class TVBoxSetParserManager(ParserManager):
"""Parses TV box set releases.""" """Parses TV box set releases."""
def collect_parsers(self): def collect_parsers(self):
"""Dynamically imports all TV box set parsers.""" """Dynamically imports all TV box set parsers."""
super().collect_parsers() super().collect_parsers()

View File

@@ -1,7 +1,7 @@
import regex as re import regex as re
from parsers.generic.parsers import DataParser from watfag.parsers.generic.parsers import DataParser
from parsers.tvboxset import TVBoxSetParser, TVBoxSetRelease from watfag.parsers.tvboxset import TVBoxSetParser, TVBoxSetRelease
patterns = [ patterns = [
re.compile( # Show Name S01-S02 (year) re.compile( # Show Name S01-S02 (year)
@@ -45,7 +45,7 @@ class TitleSeasonsParser(DataParser, TVBoxSetParser):
for pattern in patterns: for pattern in patterns:
match = pattern.match(self.release.original_text) match = pattern.match(self.release.original_text)
if match: if match:
self.release.show_title = match.group("title").replace(".", " ").replace("_", " ").strip() if match.group("title") else "" self.release.title = match.group("title").replace(".", " ").replace("_", " ").strip() if match.group("title") else ""
season_start = int(match.group("season_start")) if match.group("season_start") else 0 season_start = int(match.group("season_start")) if match.group("season_start") else 0
season_end = int(match.group("season_end")) if "season_end" in match.groupdict() and match.group("season_end") else season_start season_end = int(match.group("season_end")) if "season_end" in match.groupdict() and match.group("season_end") else season_start
self.release.seasons = f"{season_start}" if season_start == season_end else f"{season_start}-{season_end}" self.release.seasons = f"{season_start}" if season_start == season_end else f"{season_start}-{season_end}"

View File

@@ -2,9 +2,10 @@ from xml.etree import ElementTree
from httpx import AsyncClient from httpx import AsyncClient
from parsers.generic import Release from watfag.parsers.generic import Release
from parsers.movie import MovieRelease, MovieParserManager from watfag.parsers.movie import MovieRelease, MovieParserManager
from parsers.tvboxset import TVBoxSetRelease, TVBoxSetParserManager from watfag.parsers.tvboxset import TVBoxSetRelease, TVBoxSetParserManager
from watfag.trackers import TrackerInfo
class Jackett: class Jackett:
@@ -13,6 +14,9 @@ class Jackett:
self.base_url = base_url self.base_url = base_url
self.movie_parser = MovieParserManager() self.movie_parser = MovieParserManager()
self.tvboxset_parser = TVBoxSetParserManager() self.tvboxset_parser = TVBoxSetParserManager()
self.tracker_info = TrackerInfo(
"https://raw.githubusercontent.com/HDVinnie/Private-Trackers-Spreadsheet/refs/heads/master/trackers.json"
)
async def get_capabilities(self): async def get_capabilities(self):
params = { params = {
@@ -45,21 +49,15 @@ class Jackett:
# Find out from categories what kind of result this is # Find out from categories what kind of result this is
if any(cat.startswith('2') for cat in attrs.get('category')): # This is a movie if any(cat.startswith('2') for cat in attrs.get('category')): # This is a movie
release = MovieRelease( release = MovieRelease(
item.find('title').text, item,
item.find('link').text, self.tracker_info
size=int(item.find('size').text),
seeders=int(attrs.get('seeders')[0]),
view_link=item.find('comments').text
) )
self.movie_parser.run_parsers(release) self.movie_parser.run_parsers(release)
releases.append(release) releases.append(release)
elif any(cat == '100027' for cat in attrs.get('category')): # This is a TV boxset elif any(cat == '100027' for cat in attrs.get('category')): # This is a TV boxset
release = TVBoxSetRelease( release = TVBoxSetRelease(
item.find('title').text, item,
item.find('link').text, self.tracker_info
size=int(item.find('size').text),
seeders=int(attrs.get('seeders')[0]),
view_link=item.find('comments').text
) )
self.tvboxset_parser.run_parsers(release) self.tvboxset_parser.run_parsers(release)
releases.append(release) releases.append(release)

19
src/watfag/trackers.py Normal file
View File

@@ -0,0 +1,19 @@
from typing import Optional
import httpx
class TrackerInfo:
def __init__(self, url):
self.url = url
self.json: Optional[dict] = None
def _refresh(self):
r = httpx.get(self.url)
r.raise_for_status()
self.json = r.json().get('trackers', [])
def get_tracker_info(self, tracker_name):
if self.json is None:
self._refresh()
return next((tracker for tracker in self.json if tracker['Name'] == tracker_name), None)