Initial commit
This commit is contained in:
commit
bc4d70ae04
|
@ -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
|
|
@ -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/
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
flake8==3.*
|
||||||
|
jedi==0.*
|
||||||
|
mypy==0.*
|
|
@ -0,0 +1,2 @@
|
||||||
|
aiohttp==3.7.*
|
||||||
|
feedparser==6.0.*
|
|
@ -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,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()
|
|
@ -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()
|
|
@ -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',
|
||||||
|
]
|
|
@ -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()
|
|
@ -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
|
|
@ -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,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
|
|
@ -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
|
Loading…
Reference in New Issue