Compare commits
8 Commits
cb4eee2cb5
...
279f0cbbf0
Author | SHA1 | Date |
---|---|---|
Derek | 279f0cbbf0 | |
Derek | 224e178b8b | |
Derek | a7e49bfcbf | |
Derek | c4bf3b03a0 | |
Derek | ae283723ad | |
Derek | 2cf9a0be4f | |
Derek | f2c573d386 | |
Derek | e5ddd166b3 |
30
README.md
30
README.md
|
@ -1,7 +1,8 @@
|
|||
# Automagnet
|
||||
RSS feed parser, primarily for use on the seven seas.
|
||||
|
||||
Extend by adding entries to the `providers` list. Includes sample provider for horriblesubs.info
|
||||
Extend by adding entries to the `providers` array in config.json.
|
||||
Includes sample provider for horriblesubs.info.
|
||||
|
||||
## Quickstart
|
||||
1. Install pip and pipenv
|
||||
|
@ -15,12 +16,29 @@ Extend by adding entries to the `providers` list. Includes sample provider for h
|
|||
pipenv install
|
||||
```
|
||||
|
||||
3. Add some shows to wants.txt
|
||||
```bash
|
||||
echo 'weeb nonsense' >> wants.txt
|
||||
3. Add some shows to the `wants` array in config.json
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
...
|
||||
],
|
||||
"wants": [
|
||||
{
|
||||
"name": "Some weeb nonsense",
|
||||
"target-quality": "1080p"
|
||||
},
|
||||
{
|
||||
"name": "More weeb nonsense",
|
||||
"min-quality": "720p"
|
||||
},
|
||||
{
|
||||
"name": "Even more weeb nonsense"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. Get some magnets, but only once, and do with them what you will.
|
||||
4. Get some magnets / torrents, but only once, and do with them what you will. Handy to run in a cronjob every so often
|
||||
```bash
|
||||
pipenv run ./automagnet.py has.txt wants.txt | transmission-cli -a
|
||||
pipenv run ./automagnet.py | transmission-cli -a
|
||||
```
|
||||
|
|
180
automagnet.py
180
automagnet.py
|
@ -4,47 +4,179 @@ import feedparser
|
|||
import subprocess
|
||||
import click
|
||||
import re
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
from operator import itemgetter
|
||||
|
||||
def printe(*args, **kwargs):
|
||||
print(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
class Resolution:
|
||||
# In decending order of quality, since indexes are used to tell whats "better"
|
||||
names = [
|
||||
('8k', '8K', '4320p'),
|
||||
('5k', '5K', '2880p', 'UHD+'),
|
||||
('4k', '4K', '2160p', 'UHD'),
|
||||
('2k', '2K', '1440p', 'QHD', 'WQHD'),
|
||||
('1080p', 'FHD'),
|
||||
('720p', 'HD', 'HDTV'),
|
||||
('480p', 'SD', 'VGA'),
|
||||
('360p', 'nHD'),
|
||||
('240p', 'QVGA'),
|
||||
('Unkown', 'None', None)
|
||||
]
|
||||
def __init__(self, _name):
|
||||
self.name = _name
|
||||
for family in self.names:
|
||||
if self.name in family:
|
||||
self.family = family
|
||||
break
|
||||
else:
|
||||
raise ValueError("Did not recongnize resolution name. See Resolution.names")
|
||||
|
||||
# Rich comparison support
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, Resolution):
|
||||
raise TypeError
|
||||
return self.names.index(self.family) > other.names.index(other.family)
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Resolution):
|
||||
raise TypeError
|
||||
return self.names.index(self.family) == other.names.index(other.family)
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
def __gt__(self, other):
|
||||
return not self.__lt__(other)
|
||||
def __le__(self, other):
|
||||
return self.__lt__(other) or self.__eq__(other)
|
||||
def __ge__(self, other):
|
||||
return self.__gt__(other) or self.__eq__(other)
|
||||
|
||||
|
||||
class Show:
|
||||
def __init__(self, name, episode=None):
|
||||
self.name = name
|
||||
try:
|
||||
self.episode = int(episode) if episode is not None else None
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("episode paramater must be a number or None")
|
||||
self.key = '{name} - {episode}'.format(name=self.name, episode=self.episode if self.episode else 'None')
|
||||
|
||||
def getConfig(self, config):
|
||||
try:
|
||||
show_config = next(want for want in config['wants'] if want['name'] == self.name)
|
||||
except StopIteration:
|
||||
show_config = None
|
||||
return show_config
|
||||
|
||||
def __repr__(self):
|
||||
return "<Show {name} - {episode}>".format(name=self.name, episode=self.episode if self.episode else 'None')
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.key)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.key == other.key
|
||||
|
||||
class Torrent:
|
||||
def __init__(self, name, link, id=None, episode=None, quality=None, encoding=None):
|
||||
self.name = name
|
||||
self.link = link
|
||||
self.id = id if id else hashlib.sha256(link).hexdigest()
|
||||
try:
|
||||
self.episode = int(episode) if episode is not None else None
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("episode paramater must be a number or None")
|
||||
self.quality = Resolution(quality)
|
||||
self.encoding = encoding
|
||||
|
||||
def isShow(self, show):
|
||||
return self.name == show.name and (show.episode is None or self.episode == show.episode)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Torrent {id} for {name} - {episode}>".format(id=self.id, name=self.name, episode=self.episode)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.id == other.id
|
||||
|
||||
# List of provider RSS feeds to parse, and a regex we use to parse them. Regex should contain at least a "name" group
|
||||
providers = [
|
||||
('https://horriblesubs.info/rss.php?res=all', '\[HorribleSubs\]\s(?P<name>.*?)(?:\s-\s(?P<episode>[0-9]+))?\s\[(?P<quality>[0-9]+.)\]\.(?P<encoding>.*)')
|
||||
]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('has_path', type=click.Path(writable=True, dir_okay=False))
|
||||
@click.argument('wants_file', type=click.File())
|
||||
def main(has_path, wants_file):
|
||||
# Get what we want and what we have from the files provided
|
||||
wants = [line.strip() for line in wants_file if not line.strip().startswith('#')]
|
||||
has = [line.strip() for line in open(has_path, 'r')]
|
||||
@click.option('--config', 'config_path', type=click.Path(readable=True, dir_okay=False), default='config.json', help="Config file in JSON format.", )
|
||||
@click.option('--seen-file', 'seen_path', type=click.Path(writable=True, dir_okay=False), default='seen.txt', help="File to store seen torrents in. Pruning is acceptable if pruned torrent id's will not appear in the RSS feeds.")
|
||||
def main(config_path='config.json', seen_path='seen.txt'):
|
||||
# Get our config
|
||||
with open(config_path, 'r') as config_file:
|
||||
config_json = ''.join(config_file.readlines())
|
||||
config = json.loads(config_json)
|
||||
|
||||
for provider, regex in providers:
|
||||
providers = config['providers']
|
||||
wants = [want['name'] for want in config['wants']]
|
||||
seen = [line.strip() for line in open(seen_path, 'r')]
|
||||
|
||||
filtered = {}
|
||||
|
||||
for provider in providers:
|
||||
# Compile and check our regex
|
||||
title_parser = re.compile(regex)
|
||||
title_parser = re.compile(provider['regex'])
|
||||
if title_parser.groupindex.get('name') is None:
|
||||
print("ERR: Regex for {url} does not contain a 'name' group")
|
||||
printe("ERROR: Regex for {provider_name} does not contain a 'name' group. Name a capture group in your regex using '(?P<name>...)'".format(provider_name=provider['name']))
|
||||
continue
|
||||
elif title_parser.groupindex.get('quality') is None or title_parser.groupindex.get('episode') is None:
|
||||
printe("WARNING: Regex for {provider_name} does not contain an 'episode' and/or 'quality' group. Unless you are sure duplicates don't exist in the feeds, add optional groups using '(?P<name>...)?'".format(provider_name=provider['name']))
|
||||
|
||||
# Get and parse rss feed
|
||||
rss = feedparser.parse(provider)
|
||||
rss = feedparser.parse(provider['url'])
|
||||
for entry in rss.entries:
|
||||
# Run regex against rss entry title (usually the file / torrent name)
|
||||
matched_title = title_parser.match(entry.title)
|
||||
if matched_title is None:
|
||||
continue
|
||||
# Check if its something we're interested in
|
||||
parsed_title = matched_title.groupdict()
|
||||
if parsed_title['name'].lower() in wants and entry.id not in has:
|
||||
# Output the magnet / torrent link
|
||||
# TODO: transmission-cli subprocess call here?
|
||||
print(entry.link)
|
||||
# Record that we have it
|
||||
# FIXME: should be derived from the magnet itself as not all providers are nice like this
|
||||
has.append(entry.id)
|
||||
torrent = Torrent(
|
||||
parsed_title['name'],
|
||||
entry.get('link') or entry.links[0].href,
|
||||
id=entry.get('id'),
|
||||
episode=parsed_title.get('episode'),
|
||||
quality=parsed_title.get('quality'),
|
||||
encoding=parsed_title.get('encoding')
|
||||
)
|
||||
show = Show(torrent.name, episode=torrent.episode)
|
||||
# Check if its something we're interested in and add it to a dict
|
||||
if torrent.name in wants and torrent.id not in seen:
|
||||
printe("Found new torrent: {torrent}".format(torrent=entry.title))
|
||||
if filtered.get(show) is None:
|
||||
filtered[show] = [torrent]
|
||||
else:
|
||||
filtered[show].append(torrent)
|
||||
# Record that we have seen it
|
||||
seen.append(torrent.id)
|
||||
|
||||
# Choose the best item
|
||||
for show, torrents in filtered.items():
|
||||
item_config = show.getConfig(config)
|
||||
# Filter
|
||||
if item_config.get('target-quality'):
|
||||
torrents = [torrent for torrent in torrents if torrent.quality == Resolution(item_config['target-quality'])]
|
||||
elif item_config.get('min-quality'):
|
||||
torrents = [torrent for torrent in torrents if torrent.quality >= Resolution(item_config['min-quality'])]
|
||||
|
||||
if len(torrents) > 0:
|
||||
best_entry = sorted(torrents, key=lambda t: t.quality)[0]
|
||||
# Output the magnet / torrent link
|
||||
printe("Choosing {torrent} as best choice for {show}".format(torrent=torrent.id, show=show.name))
|
||||
# TODO: transmission-cli subprocess call here?
|
||||
print(best_entry.link)
|
||||
else:
|
||||
printe("No torrents for {show} pass quality restrictions".format(show=show.name))
|
||||
|
||||
# Save our records to a file for later use
|
||||
with open(has_path, 'w') as has_file:
|
||||
has_file.write('\n'.join(has))
|
||||
with open(seen_path, 'w') as seen_file:
|
||||
seen_file.write('\n'.join(seen))
|
||||
|
||||
# Usual python stuff
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "HorribleSubs",
|
||||
"url": "https://horriblesubs.info/rss.php?res=all",
|
||||
"regex": "\\[HorribleSubs\\]\\s(?P<name>.*?)(?:\\s-\\s(?P<episode>[0-9]+))?\\s\\[(?P<quality>[0-9]+.)\\]\\.(?P<encoding>.*)"
|
||||
}
|
||||
],
|
||||
"wants": [
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue