diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..e2997e4 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,4 @@ +[mypy-feedparser.*] +ignore_missing_imports=true + +[mypy] diff --git a/requirements.txt b/requirements.txt index 338eac9..e0f5b89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ aiohttp==3.7.* feedparser==6.0.* +inject==4.3.* diff --git a/ytrssil/bindings.py b/ytrssil/bindings.py new file mode 100644 index 0000000..b27a3ff --- /dev/null +++ b/ytrssil/bindings.py @@ -0,0 +1,17 @@ +from inject import Binder, configure + +from ytrssil.config import Configuration +from ytrssil.parser import Parser, create_feed_parser +from ytrssil.repository import ChannelRepository, create_channel_repository + + +def dependency_configuration(binder: Binder) -> None: + config = Configuration() + binder.bind(Configuration, config) + binder.bind_to_provider + binder.bind_to_constructor(ChannelRepository, create_channel_repository) + binder.bind_to_constructor(Parser, create_feed_parser) + + +def setup_dependencies() -> None: + configure(dependency_configuration) diff --git a/ytrssil/cli.py b/ytrssil/cli.py index 9054483..2b1ffb3 100644 --- a/ytrssil/cli.py +++ b/ytrssil/cli.py @@ -1,20 +1,26 @@ from os import execv, fork from sys import stderr +from inject import autoparams + +from ytrssil.bindings import setup_dependencies from ytrssil.constants import mpv_options -from ytrssil.repository import ChannelRepository from ytrssil.fetch import fetch_new_videos from ytrssil.query import query +from ytrssil.repository import ChannelRepository -def main() -> int: - with ChannelRepository() as repository: - channels, new_videos = fetch_new_videos(repository) +class NoVideoSelected(Exception): + pass + +@autoparams() +def run(repository_manager: ChannelRepository) -> None: + with repository_manager as repository: + channels, new_videos = fetch_new_videos() selected_videos = query(new_videos) if not selected_videos: - print('No video selected', file=stderr) - return 1 + raise NoVideoSelected video_urls = [video.url for video in selected_videos] cmd = ['/usr/bin/mpv', *mpv_options, *video_urls] @@ -24,7 +30,16 @@ def main() -> int: for video in selected_videos: selected_channel = channels[video.channel_id] selected_channel.mark_video_as_watched(video) - repository.update_channel(selected_channel) + repository.update_video(video, True) + + +def main() -> int: + setup_dependencies() + try: + run() + except NoVideoSelected: + print('No video selected', file=stderr) + return 1 return 0 diff --git a/ytrssil/config.py b/ytrssil/config.py index f805936..c4982bf 100644 --- a/ytrssil/config.py +++ b/ytrssil/config.py @@ -1,9 +1,16 @@ import os from collections.abc import Iterator +from dataclasses import dataclass from ytrssil.constants import config_dir +@dataclass +class Configuration: + channel_repository_type: str = 'sqlite' + feed_parser_type: str = 'feedparser' + + def get_feed_urls() -> Iterator[str]: file_path = os.path.join(config_dir, 'feeds') with open(file_path, 'r') as f: diff --git a/ytrssil/constants.py b/ytrssil/constants.py index 9334636..78c9546 100644 --- a/ytrssil/constants.py +++ b/ytrssil/constants.py @@ -1,6 +1,5 @@ import os - config_prefix: str try: config_prefix = os.environ['XDG_CONFIG_HOME'] @@ -10,5 +9,5 @@ except KeyError: config_dir: str = os.path.join(config_prefix, 'ytrssil') mpv_options: list[str] = [ '--no-terminal', - '--ytdl-format=bestvideo[height<=?1080][vcodec!=vp9]+bestaudio/best', + '--ytdl-format=bestvideo[height<=?1080]+bestaudio/best', ] diff --git a/ytrssil/datatypes.py b/ytrssil/datatypes.py index c54300e..ee92350 100644 --- a/ytrssil/datatypes.py +++ b/ytrssil/datatypes.py @@ -23,11 +23,15 @@ class Channel: 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 + def add_video(self, video: Video) -> bool: + if ( + video.video_id in self.watched_videos + or video.video_id in self.new_videos + ): + return False self.new_videos[video.video_id] = video + return True def remove_old_videos(self) -> None: vid_list: list[Video] = sorted( @@ -41,3 +45,6 @@ class Channel: self.new_videos.pop(video.video_id) self.watched_videos[video.video_id] = video self.remove_old_videos() + + def __str__(self) -> str: + return f'{self.name} - {len(self.new_videos)}' diff --git a/ytrssil/fetch.py b/ytrssil/fetch.py index 054656a..3483924 100644 --- a/ytrssil/fetch.py +++ b/ytrssil/fetch.py @@ -2,11 +2,12 @@ from asyncio import gather, run from collections.abc import Iterable from aiohttp import ClientResponse, ClientSession +from inject import autoparams from ytrssil.config import get_feed_urls from ytrssil.datatypes import Channel, Video -from ytrssil.repository import ChannelRepository from ytrssil.parser import Parser +from ytrssil.repository import ChannelRepository async def request(session: ClientSession, url: str) -> ClientResponse: @@ -24,11 +25,13 @@ async def fetch_feeds(urls: Iterable[str]) -> Iterable[str]: ] +@autoparams('parser', 'repository') def fetch_new_videos( + *, + parser: Parser, 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)): diff --git a/ytrssil/parser.py b/ytrssil/parser.py index 6e3b78d..ae63df6 100644 --- a/ytrssil/parser.py +++ b/ytrssil/parser.py @@ -1,15 +1,25 @@ +from abc import ABCMeta, abstractmethod from datetime import datetime import feedparser +from inject import autoparams +from ytrssil.config import Configuration from ytrssil.datatypes import Channel, Video from ytrssil.repository import ChannelNotFound, ChannelRepository -class Parser: +class Parser(metaclass=ABCMeta): + @autoparams('channel_repository') def __init__(self, channel_repository: ChannelRepository) -> None: self.repository = channel_repository + @abstractmethod + def __call__(self, feed_content: str) -> Channel: + pass + + +class FeedparserParser(Parser): def __call__(self, feed_content: str) -> Channel: d = feedparser.parse(feed_content) channel_id: str = d['feed']['yt_channelid'] @@ -21,17 +31,27 @@ class Parser: name=d['feed']['title'], url=d['feed']['link'], ) - self.repository.update_channel(channel) + self.repository.create_channel(channel) for entry in d['entries']: - channel.add_video(Video( + 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, - )) + ) + if channel.add_video(video): + self.repository.add_new_video(channel, video) - self.repository.update_channel(channel) return channel + + +@autoparams() +def create_feed_parser(config: Configuration) -> Parser: + parser_type = config.feed_parser_type + if parser_type == 'feedparser': + return FeedparserParser() + else: + raise Exception(f'Unknown feed parser type: "{parser_type}"') diff --git a/ytrssil/query.py b/ytrssil/query.py index 65e8efc..792663f 100644 --- a/ytrssil/query.py +++ b/ytrssil/query.py @@ -1,4 +1,4 @@ -from subprocess import Popen, PIPE +from subprocess import PIPE, Popen from ytrssil.datatypes import Video diff --git a/ytrssil/repository.py b/ytrssil/repository.py index 9e6b7ab..55d6f40 100644 --- a/ytrssil/repository.py +++ b/ytrssil/repository.py @@ -1,32 +1,191 @@ from __future__ import annotations -import os -import shelve +import os +from abc import ABCMeta, abstractmethod +from datetime import datetime +from sqlite3 import connect +from typing import Any, Union + +from inject import autoparams + +from ytrssil.config import Configuration from ytrssil.constants import config_dir -from ytrssil.datatypes import Channel +from ytrssil.datatypes import Channel, Video class ChannelNotFound(Exception): pass -class ChannelRepository: +class ChannelRepository(metaclass=ABCMeta): + @abstractmethod + def __enter__(self) -> ChannelRepository: + pass + + @abstractmethod + def __exit__( + self, + exc_type: Any, + exc_value: Any, + exc_traceback: Any, + ) -> None: + pass + + @abstractmethod + def get_channel(self, channel_id: str) -> Channel: + pass + + @abstractmethod + def create_channel(self, channel: Channel) -> None: + pass + + @abstractmethod + def add_new_video(self, channel: Channel, video: Video) -> None: + pass + + @abstractmethod + def update_video(self, video: Video, watched: bool) -> None: + pass + + +class SqliteChannelRepository(ChannelRepository): def __init__(self) -> None: os.makedirs(config_dir, exist_ok=True) - self.file_path: str = os.path.join(config_dir, 'shelf') + self.file_path: str = os.path.join(config_dir, 'channels.db') + self.setup_database() + + def setup_database(self) -> None: + connection = connect(self.file_path) + cursor = connection.cursor() + cursor.execute('PRAGMA foreign_keys = ON') + cursor.execute( + 'CREATE TABLE IF NOT EXISTS channels (' + 'channel_id VARCHAR PRIMARY KEY, name VARCHAR, url VARCHAR UNIQUE)' + ) + cursor.execute( + 'CREATE TABLE IF NOT EXISTS videos (' + 'video_id VARCHAR PRIMARY KEY, name VARCHAR, url VARCHAR UNIQUE, ' + 'timestamp VARCHAR, watched BOOLEAN, channel_id VARCHAR, ' + 'FOREIGN KEY(channel_id) REFERENCES channels(channel_id))' + ) + connection.commit() + connection.close() def __enter__(self) -> ChannelRepository: - self.repository = shelve.open(self.file_path) + self.connection = connect(self.file_path) return self - def __exit__(self, exc_type, exc_value, exc_traceback) -> None: - self.repository.close() + def __exit__( + self, + exc_type: Any, + exc_value: Any, + exc_traceback: Any, + ) -> None: + self.connection.close() + + def get_channel_as_dict(self, channel: Channel) -> dict[str, str]: + return { + 'channel_id': channel.channel_id, + 'name': channel.name, + 'url': channel.url, + } + + def get_video_as_dict(self, video: Video, watched: bool) -> dict[str, Any]: + return { + 'video_id': video.video_id, + 'name': video.name, + 'url': video.url, + 'timestamp': video.timestamp.isoformat(), + 'watched': watched, + 'channel_id': video.channel_id, + } + + def get_videos(self, channel: Channel) -> list[tuple[Video, bool]]: + cursor = self.connection.cursor() + cursor.execute( + 'SELECT video_id, name, url, timestamp, watched ' + 'FROM videos WHERE channel_id=:channel_id', + {'channel_id': channel.channel_id}, + ) + ret: list[tuple[Video, bool]] = [] + video_data: tuple[str, str, str, str, bool] + for video_data in cursor.fetchall(): + ret.append(( + Video( + video_id=video_data[0], + name=video_data[1], + url=video_data[2], + timestamp=datetime.fromisoformat(video_data[3]), + channel_id=channel.channel_id, + channel_name=channel.name, + ), + video_data[4] + )) + + return ret def get_channel(self, channel_id: str) -> Channel: - try: - return self.repository[channel_id] - except KeyError as e: - raise ChannelNotFound(e) + cursor = self.connection.cursor() + cursor.execute( + 'SELECT * FROM channels WHERE channel_id=:channel_id', + {'channel_id': channel_id}, + ) + channel_data: Union[tuple[str, str, str], None] = cursor.fetchone() + if channel_data is None: + raise ChannelNotFound + + channel = Channel( + channel_id=channel_data[0], + name=channel_data[1], + url=channel_data[2], + ) + for video, watched in self.get_videos(channel): + if watched: + channel.watched_videos[video.video_id] = video + else: + channel.new_videos[video.video_id] = video + + return channel + + def create_channel(self, channel: Channel) -> None: + cursor = self.connection.cursor() + cursor.execute( + 'INSERT INTO channels VALUES (:channel_id, :name, :url)', + self.get_channel_as_dict(channel), + ) + self.connection.commit() def update_channel(self, channel: Channel) -> None: - self.repository[channel.channel_id] = channel + cursor = self.connection.cursor() + cursor.execute( + 'UPDATE channels SET channel_id = :channel_id, name = :name, ' + 'url = :url WHERE channel_id=:channel_id', + self.get_channel_as_dict(channel), + ) + self.connection.commit() + + def add_new_video(self, channel: Channel, video: Video) -> None: + cursor = self.connection.cursor() + cursor.execute( + 'INSERT INTO videos VALUES ' + '(:video_id, :name, :url, :timestamp, :watched, :channel_id)', + self.get_video_as_dict(video, False), + ) + self.connection.commit() + + def update_video(self, video: Video, watched: bool) -> None: + cursor = self.connection.cursor() + cursor.execute( + 'UPDATE videos SET watched = :watched WHERE video_id=:video_id', + {'watched': watched, 'video_id': video.video_id}, + ) + self.connection.commit() + + +@autoparams() +def create_channel_repository(config: Configuration) -> ChannelRepository: + repo_type = config.channel_repository_type + if repo_type == 'sqlite': + return SqliteChannelRepository() + else: + raise Exception(f'Unknown channel repository type: "{repo_type}"')