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