Initial commit

This commit is contained in:
Pavle Portic 2021-07-30 11:48:32 +02:00
commit bc4d70ae04
Signed by: TheEdgeOfRage
GPG Key ID: F2AB38285780DE3D
16 changed files with 370 additions and 0 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab

67
.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Environments
.*env
.*venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/

27
makefile Normal file
View File

@ -0,0 +1,27 @@
NAME = ytrssil
TESTS = tests
FILES_PY = $(shell find $(CURDIR)/$(NAME) $(CURDIR)/$(TESTS) -type f -name "*.py")
TEST_PY = $(shell find $(CURDIR)/$(TESTS) -type f -name "*.py")
setup-dev:
pip install -r requirements-dev.txt
pip install -e .
test:
python -m unittest discover $(CURDIR)/$(TESTS)
flake8:
@flake8 $(FILES_PY)
mypy:
@mypy --strict $(FILES_PY)
isort:
@isort -c $(FILES_PY)
validate: flake8 mypy isort
coverage:
python -m coverage run -m unittest discover $(CURDIR)/$(TESTS)
python -m coverage html

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
flake8==3.*
jedi==0.*
mypy==0.*

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
aiohttp==3.7.*
feedparser==6.0.*

26
setup.py Normal file
View File

@ -0,0 +1,26 @@
from setuptools import setup # type: ignore
with open('requirements.txt') as f:
required = f.read().splitlines()
setup(
name='ytrssil',
description=(
'Subscribe to YouTube RSS feeds and keep track of watched videos'
),
homepage='https://gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil',
repository='https://gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil',
documentation='https://gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil',
version='0.0.0',
packages=['ytrssil'],
package_data={'': ['py.typed']},
include_package_data=True,
install_requires=required,
entry_points={
'console_scripts': [
'ytrssil = ytrssil.cli:main',
],
},
)

0
ytrssil/__init__.py Normal file
View File

33
ytrssil/cli.py Normal file
View File

@ -0,0 +1,33 @@
from os import execv, fork
from sys import stderr
from ytrssil.constants import mpv_options
from ytrssil.repository import ChannelRepository
from ytrssil.fetch import fetch_new_videos
from ytrssil.query import query
def main() -> int:
with ChannelRepository() as repository:
channels, new_videos = fetch_new_videos(repository)
selected_videos = query(new_videos)
if not selected_videos:
print('No video selected', file=stderr)
return 1
video_urls = [video.url for video in selected_videos]
cmd = ['/usr/bin/mpv', *mpv_options, *video_urls]
if (fork() == 0):
execv(cmd[0], cmd)
for video in selected_videos:
selected_channel = channels[video.channel_id]
selected_channel.mark_video_as_watched(video)
repository.update_channel(selected_channel)
return 0
if __name__ == '__main__':
main()

11
ytrssil/config.py Normal file
View File

@ -0,0 +1,11 @@
import os
from collections.abc import Iterator
from ytrssil.constants import config_dir
def get_feed_urls() -> Iterator[str]:
file_path = os.path.join(config_dir, 'feeds')
with open(file_path, 'r') as f:
for line in f:
yield line.strip()

14
ytrssil/constants.py Normal file
View File

@ -0,0 +1,14 @@
import os
config_prefix: str
try:
config_prefix = os.environ['XDG_CONFIG_HOME']
except KeyError:
config_prefix = os.path.expanduser('~/.config')
config_dir: str = os.path.join(config_prefix, 'ytrssil')
mpv_options: list[str] = [
'--no-terminal',
'--ytdl-format=bestvideo[height<=?1080][vcodec!=vp9]+bestaudio/best',
]

43
ytrssil/datatypes.py Normal file
View File

@ -0,0 +1,43 @@
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Video:
video_id: str
name: str
url: str
timestamp: datetime
channel_id: str
channel_name: str
def __str__(self) -> str:
return f'{self.channel_name} - {self.name} - {self.video_id}'
@dataclass
class Channel:
channel_id: str
name: str
url: str
new_videos: dict[str, Video] = field(default_factory=lambda: dict())
watched_videos: dict[str, Video] = field(default_factory=lambda: dict())
def add_video(self, video: Video) -> None:
if video.video_id in self.watched_videos:
return
self.new_videos[video.video_id] = video
def remove_old_videos(self) -> None:
vid_list: list[Video] = sorted(
self.watched_videos.values(),
key=lambda x: x.timestamp,
)
for video in vid_list[15:]:
self.watched_videos.pop(video.video_id)
def mark_video_as_watched(self, video: Video) -> None:
self.new_videos.pop(video.video_id)
self.watched_videos[video.video_id] = video
self.remove_old_videos()

39
ytrssil/fetch.py Normal file
View File

@ -0,0 +1,39 @@
from asyncio import gather, run
from collections.abc import Iterable
from aiohttp import ClientResponse, ClientSession
from ytrssil.config import get_feed_urls
from ytrssil.datatypes import Channel, Video
from ytrssil.repository import ChannelRepository
from ytrssil.parser import Parser
async def request(session: ClientSession, url: str) -> ClientResponse:
return await session.request(method='GET', url=url)
async def fetch_feeds(urls: Iterable[str]) -> Iterable[str]:
async with ClientSession() as session:
responses: list[ClientResponse] = await gather(*[
request(session, url) for url in urls
])
return [
await response.text(encoding='UTF-8')
for response in responses
]
def fetch_new_videos(
repository: ChannelRepository,
) -> tuple[dict[str, Channel], dict[str, Video]]:
feed_urls = get_feed_urls()
parser = Parser(repository)
channels: dict[str, Channel] = {}
new_videos: dict[str, Video] = {}
for feed in run(fetch_feeds(feed_urls)):
channel = parser(feed)
channels[channel.channel_id] = channel
new_videos.update(channel.new_videos)
return channels, new_videos

37
ytrssil/parser.py Normal file
View File

@ -0,0 +1,37 @@
from datetime import datetime
import feedparser
from ytrssil.datatypes import Channel, Video
from ytrssil.repository import ChannelNotFound, ChannelRepository
class Parser:
def __init__(self, channel_repository: ChannelRepository) -> None:
self.repository = channel_repository
def __call__(self, feed_content: str) -> Channel:
d = feedparser.parse(feed_content)
channel_id: str = d['feed']['yt_channelid']
try:
channel = self.repository.get_channel(channel_id)
except ChannelNotFound:
channel = Channel(
channel_id=channel_id,
name=d['feed']['title'],
url=d['feed']['link'],
)
self.repository.update_channel(channel)
for entry in d['entries']:
channel.add_video(Video(
video_id=entry['yt_videoid'],
name=entry['title'],
url=entry['link'],
timestamp=datetime.fromisoformat(entry['published']),
channel_id=channel.channel_id,
channel_name=channel.name,
))
self.repository.update_channel(channel)
return channel

0
ytrssil/py.typed Normal file
View File

24
ytrssil/query.py Normal file
View File

@ -0,0 +1,24 @@
from subprocess import Popen, PIPE
from ytrssil.datatypes import Video
def query(videos: dict[str, Video]) -> list[Video]:
p = Popen(
['fzf', '-m'],
stdout=PIPE,
stdin=PIPE,
)
input_bytes = '\n'.join(map(str, videos.values())).encode('UTF-8')
stdout, _ = p.communicate(input=input_bytes)
videos_str: list[str] = stdout.decode('UTF-8').strip().split('\n')
ret: list[Video] = []
for video_str in videos_str:
*_, video_id = video_str.split(' - ')
try:
ret.append(videos[video_id])
except KeyError:
pass
return ret

32
ytrssil/repository.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import annotations
import os
import shelve
from ytrssil.constants import config_dir
from ytrssil.datatypes import Channel
class ChannelNotFound(Exception):
pass
class ChannelRepository:
def __init__(self) -> None:
os.makedirs(config_dir, exist_ok=True)
self.file_path: str = os.path.join(config_dir, 'shelf')
def __enter__(self) -> ChannelRepository:
self.repository = shelve.open(self.file_path)
return self
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
self.repository.close()
def get_channel(self, channel_id: str) -> Channel:
try:
return self.repository[channel_id]
except KeyError as e:
raise ChannelNotFound(e)
def update_channel(self, channel: Channel) -> None:
self.repository[channel.channel_id] = channel