Compare commits

..

No commits in common. "f4c81e346a0182d21fde04cfc52613ebccdf959e" and "8e74f0616430788cd9b503bff5d3abc5ba094d19" have entirely different histories.

7 changed files with 120 additions and 319 deletions

View file

@ -6,7 +6,6 @@
with_items: with_items:
- ".config/Xresources" - ".config/Xresources"
- ".config/rofi" - ".config/rofi"
- ".local/share/rofi/themes"
- ".local/bin" - ".local/bin"
- ".local/share/fonts" - ".local/share/fonts"
- ".config/qutebrowser" - ".config/qutebrowser"
@ -109,24 +108,17 @@
- color - color
when: display_server == 'x11' when: display_server == 'x11'
- name: Set base16 theme for rofi < 1.4 - name: Set base16 theme for rofi
copy: copy:
content: "{{ base16_schemes['schemes'][base16_scheme]['rofi']['themes']['base16-' + base16_scheme + '.config'] }}" content: "{{ base16_schemes['schemes'][base16_scheme]['rofi']['themes']['base16-' + base16_scheme + '.' + item] }}"
dest: "{{ ansible_env.HOME }}/.config/rofi/theme.config" dest: "{{ ansible_env.HOME }}/.config/rofi/theme.{{ item }}"
mode: "u=rw,g=r,o=r" mode: "u=rw,g=r,o=r"
with_items:
- rasi
- config
tags: tags:
- color - color
- name: Set base16 theme for rofi >= 1.4
copy:
content: "{{ base16_schemes['schemes'][base16_scheme]['rofi']['themes']['base16-' + base16_scheme + '.rasi'] }}"
dest: "{{ ansible_env.HOME }}/.local/share/rofi/themes/current.rasi"
mode: "u=rw,g=r,o=r"
tags:
- color
- g
when: no
- name: Configure Dunst - name: Configure Dunst
template: template:
src: "{{ ansible_env.HOME }}/.config/dunst/dunstrc.j2" src: "{{ ansible_env.HOME }}/.config/dunst/dunstrc.j2"

View file

@ -1 +1,3 @@
theme.config theme.config
theme.rasi

View file

@ -1,4 +1,8 @@
#include "theme.config" #include "theme.config"
rofi.theme: theme rofi.theme: theme
rofi.cycle: true
rofi.case-sensitive: false
rofi.scroll-method: 0
rofi.show-match: true
rofi.lazy-grab: false rofi.lazy-grab: false
rofi.matching: regex rofi.matching: regex

View file

@ -1,6 +0,0 @@
configuration {
theme: "current";
lazy-grab: false;
matching: "regex";
}

View file

@ -1,4 +1,3 @@
coloredlogs>=10.0<11 coloredlogs>=10.0<11
progressbar2>=3.47.0<4 progressbar2>=3.47.0<4
yt-dlp>=2021.10.22 youtube-dl>=2021.6.6
ConfigArgParse>=1.5<2

View file

@ -8,19 +8,18 @@ The common use case would be a feed from an RSS aggregator
with the unread items (non-video links are ignored). with the unread items (non-video links are ignored).
""" """
# TODO Distribute this correclty, in the meanwhile please do
# pip install --user coloredlogs ConfigArgParse yt-dlp
import enum import enum
import functools import functools
import logging import logging
import os import os
import pickle import pickle
import random
import re
import subprocess
import sys import sys
import typing import typing
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import urllib.error
from xml.dom import minidom from xml.dom import minidom
import coloredlogs import coloredlogs
@ -29,7 +28,6 @@ import yt_dlp as youtube_dl
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# TODO Lockfile, or a way to parallel watch and download
def configure_logging(args: configargparse.Namespace) -> None: def configure_logging(args: configargparse.Namespace) -> None:
# Configure logging # Configure logging
@ -43,78 +41,52 @@ def configure_logging(args: configargparse.Namespace) -> None:
logger=log, logger=log,
) )
class RVCommand(enum.Enum):
download = "download"
list = "list"
class RVElement: class RVElement:
title: str
link: str
# creator: str
# description: str
# date: datetime.datetime
guid: int
parent: "RVDatabase" parent: "RVDatabase"
item: minidom.Element
was_downloaded: bool
watched: bool
def __init__(self, parent: "RVDatabase", item: minidom.Element) -> None: def __init__(self, parent: "RVDatabase", item: minidom.Element) -> None:
self.parent = parent def get_data(tag_name: str) -> str:
self.item = item nodes = item.getElementsByTagName(tag_name)
self.was_downloaded = False
self.watched = False
def get_tag_data(self, tag_name: str) -> str:
nodes = self.item.getElementsByTagName(tag_name)
if len(nodes) != 1: if len(nodes) != 1:
raise KeyError(f"Exepected 1 tag `{tag_name}`, got {len(nodes)}.") raise RuntimeError(f"Exepected 1 tag `{tag_name}`, got {len(nodes)}.")
children = nodes[0].childNodes children = nodes[0].childNodes
if len(children) != 1: if len(children) != 1:
raise KeyError( raise RuntimeError(
f"Exepected 1 children for tag `{tag_name}`, got {len(children)}." f"Exepected 1 children for tag `{tag_name}`, got {len(children)}."
) )
return children[0].data return children[0].data
@property self.title = get_data("title")
def title(self) -> str: self.link = get_data("link")
return self.get_tag_data("title") # self.creator = get_data("dc:creator")
# self.description = get_data("description")
# self.date = get_data("pubDate")
self.guid = int(get_data("guid"))
@property self.parent = parent
def link(self) -> str:
return self.get_tag_data("link")
@property def read_cache(self, cache: "RVElement") -> None:
def creator(self) -> typing.Optional[str]: if "ytdl_infos" in cache.__dict__:
try:
return self.get_tag_data("dc:creator")
except KeyError:
return None
@property
def description(self) -> str:
# TODO Testing
return self.get_tag_data("description")
@property
def date(self) -> str:
# TODO datetime format
return self.get_tag_data("pubDate")
@property
def guid(self) -> int:
return int(self.get_tag_data("guid"))
@property
def is_researched(self) -> bool:
return "ytdl_infos" in self.__dict__
def salvage_cache(self, cache: "RVElement") -> None:
if cache.is_researched:
self.__dict__["ytdl_infos"] = cache.__dict__["ytdl_infos"] self.__dict__["ytdl_infos"] = cache.__dict__["ytdl_infos"]
log.debug(f"From cache: {self}") log.debug(f"From cache: {self}")
if cache.was_downloaded:
self.was_downloaded = True
if cache.watched:
self.watched = True
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.guid}: {self.creator} {self.title} {self.link}" return f"{self.title} {self.link}"
@property @property
def downloaded(self) -> bool: def downloaded(self) -> bool:
if not self.is_researched: if "ytdl_infos" not in self.__dict__:
return False return False
return os.path.isfile(self.filepath) return os.path.isfile(self.filepath)
@ -123,11 +95,9 @@ class RVElement:
log.info(f"Researching: {self}") log.info(f"Researching: {self}")
try: try:
infos = self.parent.ytdl_dry.extract_info(self.link) infos = self.parent.ytdl_dry.extract_info(self.link)
except KeyboardInterrupt as e: except BaseException as e:
raise e
except youtube_dl.utils.DownloadError as e:
# TODO Still raise in case of temporary network issue # TODO Still raise in case of temporary network issue
log.warning(e) log.warn(e)
infos = None infos = None
# Apparently that thing is transformed from a LazyList # Apparently that thing is transformed from a LazyList
# somewhere in the normal yt_dlp process # somewhere in the normal yt_dlp process
@ -143,10 +113,15 @@ class RVElement:
return infos return infos
@property @property
def duration(self) -> int: def skip(self) -> bool:
assert self.is_video assert self.is_video
assert self.ytdl_infos assert self.ytdl_infos
return self.ytdl_infos["duration"] if (
self.parent.args.max_duration > 0
and self.ytdl_infos["duration"] > self.parent.args.max_duration
):
return True
return False
@property @property
def is_video(self) -> bool: def is_video(self) -> bool:
@ -156,7 +131,6 @@ class RVElement:
@property @property
def filepath(self) -> str: def filepath(self) -> str:
assert self.is_video assert self.is_video
# TODO This doesn't change the extension to mkv when the formats are incomaptible
return self.parent.ytdl_dry.prepare_filename(self.ytdl_infos) return self.parent.ytdl_dry.prepare_filename(self.ytdl_infos)
@property @property
@ -167,102 +141,22 @@ class RVElement:
def download(self) -> None: def download(self) -> None:
assert self.is_video assert self.is_video
log.info(f"Downloading: {self}") log.info(f"Downloading: {self}")
if not self.parent.args.dryrun: if self.parent.args.dryrun:
return
self.parent.ytdl.process_ie_result(self.ytdl_infos, True, {}) self.parent.ytdl.process_ie_result(self.ytdl_infos, True, {})
self.was_downloaded = True
self.parent.save()
def preload(self) -> None: def act(self) -> None:
assert self.is_video
if self.downloaded:
log.debug(f"Currently downloaded: {self}")
return
if self.was_downloaded:
log.debug(f"Downloaded previously: {self}")
return
self.download()
MATCHES_DURATION_MULTIPLIERS = {"s": 1, "m": 60, "h": 3600, None: 1}
MATCHES_DURATION_COMPARATORS = {
"<": int.__lt__,
"-": int.__lt__,
">": int.__gt__,
"+": int.__gt__,
"=": int.__eq__,
None: int.__le__,
}
def matches_filter(self, args: configargparse.Namespace) -> bool:
if args.seen != "any" and (args.seen == "seen") != self.watched:
log.debug(f"Not {args.seen}: {self}")
return False
if args.title and not re.search(args.title, self.title):
log.debug(f"Title not matching {args.title}: {self}")
return False
if args.guid and not re.search(args.guid, str(self.guid)):
log.debug(f"Guid not matching {args.guid}: {self}")
return False
if args.link and not re.search(args.link, self.link):
log.debug(f"Link not matching {args.link}: {self}")
return False
if args.creator and (not self.creator or not re.search(args.creator, self.creator)):
log.debug(f"Creator not matching {args.creator}: {self}")
return False
if not self.is_video: if not self.is_video:
log.debug(f"Not a video: {self}") log.debug(f"Not a video: {self}")
return False return
if args.duration: if self.downloaded:
dur = args.duration log.debug(f"Already downloaded: {self}")
return
mult_index = dur[-1].lower() if self.skip:
if mult_index.isdigit(): log.debug(f"Skipped: {self}")
mult_index = None return
else:
dur = dur[:-1]
try:
multiplier = self.MATCHES_DURATION_MULTIPLIERS[mult_index]
except IndexError:
raise ValueError(f"Unknown duration multiplier: {mult_index}")
comp_index = dur[0]
if comp_index.isdigit():
comp_index = None
else:
dur = dur[1:]
try:
comparator = self.MATCHES_DURATION_COMPARATORS[comp_index]
except IndexError:
raise ValueError(f"Unknown duration comparator: {comp_index}")
duration = int(dur)
if not comparator(self.duration, duration * multiplier):
log.debug(f"Duration {self.duration} not matching {args.duration}: {self}")
return False
return True
def watch(self) -> None:
if not self.downloaded:
self.download() self.download()
cmd = ["mpv", self.filepath]
log.debug(f"Running {cmd}")
if not self.parent.args.dryrun:
proc = subprocess.run(cmd)
proc.check_returncode()
self.watched = True
self.parent.save()
def clean(self) -> None:
assert self.is_video
log.info(f"Removing gone video: {self.filename}*")
for file in os.listdir():
if file.startswith(self.filename):
log.debug(f"Removing file: {file}")
if not self.parent.args.dryrun:
os.unlink(file)
class RVDatabase: class RVDatabase:
SAVE_FILE = ".cache.p" SAVE_FILE = ".cache.p"
@ -274,7 +168,6 @@ class RVDatabase:
self.args = args self.args = args
def save(self) -> None: def save(self) -> None:
log.debug("Saving cache")
if self.args.dryrun: if self.args.dryrun:
return return
with open(self.SAVE_FILE, "wb") as save_file: with open(self.SAVE_FILE, "wb") as save_file:
@ -286,50 +179,30 @@ class RVDatabase:
with open(cls.SAVE_FILE, "rb") as save_file: with open(cls.SAVE_FILE, "rb") as save_file:
return pickle.load(save_file) return pickle.load(save_file)
except (TypeError, AttributeError, EOFError): except (TypeError, AttributeError, EOFError):
log.warning("Corrupt / outdated cache, it will be rebuilt.") log.warn("Corrupt / outdated cache, it will be rebuilt.")
except FileNotFoundError: except FileNotFoundError:
pass pass
return None return None
def salvage_cache(self, cache: "RVDatabase") -> None: def read_cache(self, cache: "RVDatabase") -> None:
log.debug(f"Salvaging cache")
cache_els = dict() cache_els = dict()
for cache_el in cache.elements: for cache_el in cache.elements:
cache_els[cache_el.guid] = cache_el cache_els[cache_el.guid] = cache_el
for el in self.elements: for el in self.elements:
if el.guid in cache_els: if el.guid in cache_els:
el.salvage_cache(cache_els[el.guid]) el.read_cache(cache_els[el.guid])
def clean_cache(self, cache: "RVDatabase") -> None:
log.debug(f"Cleaning cache")
self_els = dict()
for self_el in self.elements:
self_els[self_el.guid] = self_el
for el in cache.elements:
if el.guid not in self_els:
if el.is_researched and el.is_video:
el.clean()
def import_cache(self, cache: "RVDatabase") -> None:
log.debug(f"Importing cache")
self.feed_xml = cache.feed_xml
self.read_feed()
@functools.cached_property
def feed_xml(self) -> minidom.Document:
log.info("Fetching RSS feed")
with urllib.request.urlopen(self.args.feed) as request:
return minidom.parse(request)
def read_feed(self) -> None: def read_feed(self) -> None:
self.elements = [] log.info("Fetching RSS feed")
for item in self.feed_xml.getElementsByTagName("item"): self.elements = list()
with urllib.request.urlopen(self.args.feed) as request:
with minidom.parse(request) as xmldoc:
for item in xmldoc.getElementsByTagName("item"):
element = RVElement(self, item) element = RVElement(self, item)
self.elements.insert(0, element) self.elements.insert(0, element)
log.debug(f"Known: {element}") log.debug(f"Known: {element}")
def clean(self) -> None: def clean(self) -> None:
log.debug("Cleaning")
filenames = set() filenames = set()
for element in self.elements: for element in self.elements:
if element.is_video: if element.is_video:
@ -343,20 +216,13 @@ class RVDatabase:
if file.startswith(filename): if file.startswith(filename):
break break
else: else:
log.info(f"Removing unknown file: {file}") log.info(f"Removing: {file}")
if not self.args.dryrun: if not self.args.dryrun:
os.unlink(file) os.unlink(file)
@property def act_all(self) -> None:
def all_researched(self) -> bool:
for element in self.elements: for element in self.elements:
if not element.is_researched: element.act()
return False
return True
def attempt_clean(self) -> None:
if self.all_researched:
self.clean()
@property @property
def ytdl_opts(self) -> dict: def ytdl_opts(self) -> dict:
@ -376,18 +242,6 @@ class RVDatabase:
def ytdl_dry(self) -> youtube_dl.YoutubeDL: def ytdl_dry(self) -> youtube_dl.YoutubeDL:
return youtube_dl.YoutubeDL(self.ytdl_dry_opts) return youtube_dl.YoutubeDL(self.ytdl_dry_opts)
def filter(self, args: configargparse.Namespace) -> typing.Iterable[RVElement]:
elements: typing.Iterable[RVElement]
if args.order == "old":
elements = self.elements
elif args.order == "new":
elements = reversed(self.elements)
elif args.order == "random":
elements_random = self.elements.copy()
random.shuffle(elements_random)
elements = elements_random
return filter(lambda el: el.matches_filter(args), elements)
def get_args() -> configargparse.Namespace: def get_args() -> configargparse.Namespace:
defaultConfigPath = os.path.join( defaultConfigPath = os.path.join(
@ -400,8 +254,6 @@ def get_args() -> configargparse.Namespace:
+ "an RSS aggregator", + "an RSS aggregator",
default_config_files=[defaultConfigPath], default_config_files=[defaultConfigPath],
) )
# Runtime settings
parser.add_argument( parser.add_argument(
"-v", "-v",
"--verbosity", "--verbosity",
@ -412,16 +264,6 @@ def get_args() -> configargparse.Namespace:
parser.add( parser.add(
"-c", "--config", required=False, is_config_file=True, help="Configuration file" "-c", "--config", required=False, is_config_file=True, help="Configuration file"
) )
parser.add(
"-n",
"--dryrun",
help="Only pretend to do actions",
action="store_const",
const=True,
default=False,
)
# Input/Output
parser.add( parser.add(
"--feed", "--feed",
help="URL of the RSS feed (must be public for now)", help="URL of the RSS feed (must be public for now)",
@ -434,31 +276,21 @@ def get_args() -> configargparse.Namespace:
env_var="RSS_VIDEOS_VIDEO_DIR", env_var="RSS_VIDEOS_VIDEO_DIR",
required=True, required=True,
) )
# Which videos
parser.add( parser.add(
"--order", "-n",
choices=("old", "new", "random"), "--dryrun",
default="old", help="Do not download the videos",
help="Sorting mechanism", action="store_const",
const=True,
default=False,
) )
parser.add("--guid", help="Regex to filter guid")
parser.add("--creator", help="Regex to filter by creator")
parser.add("--title", help="Regex to filter by title")
parser.add("--link", help="Regex to filter by link")
parser.add("--duration", help="Comparative to filter by duration")
parser.add("--seen", choices=("seen","unseen","any"), default="unseen", help="Only include seen/unseen/any videos")
# TODO Envrionment variables
parser.add( parser.add(
"--max-duration", "--max-duration",
help="(Deprecated, use --duration instead)", help="Skip video longer than this amount of seconds",
env_var="RSS_VIDEOS_MAX_DURATION", env_var="RSS_VIDEOS_MAX_DURATION",
type=int, type=int,
default=0, default=0,
) )
# TODO Allow to ask
# How to download
parser.add( parser.add(
"--format", "--format",
help="Use this format to download videos." help="Use this format to download videos."
@ -473,17 +305,17 @@ def get_args() -> configargparse.Namespace:
action="store_true", action="store_true",
) )
parser.add( parser.set_defaults(subcommand=RVCommand.download)
"action", subparsers = parser.add_subparsers(title="subcommand")
nargs="?",
choices=("download", "list", "watch", "binge", "clean"), sc_download = subparsers.add_parser("download")
default="download", sc_download.set_defaults(subcommand=RVCommand.download)
)
sc_list = subparsers.add_parser("list")
sc_list.set_defaults(subcommand=RVCommand.list)
args = parser.parse_args() args = parser.parse_args()
args.videos = os.path.realpath(os.path.expanduser(args.videos)) args.videos = os.path.realpath(os.path.expanduser(args.videos))
if not args.duration and args.max_duration:
args.duration = str(args.max_duration)
return args return args
@ -495,37 +327,22 @@ def main() -> None:
os.makedirs(args.videos, exist_ok=True) os.makedirs(args.videos, exist_ok=True)
os.chdir(args.videos) os.chdir(args.videos)
if args.subcommand == RVCommand.download:
database = RVDatabase(args) database = RVDatabase(args)
cache = RVDatabase.load()
try:
database.read_feed() database.read_feed()
except urllib.error.URLError as err: cache = RVDatabase.load()
if args.action == "download" or not cache:
raise err
else:
log.warning("Cannot fetch RSS feed, using cached feed.", err)
database.import_cache(cache)
if cache: if cache:
database.salvage_cache(cache) database.read_cache(cache)
database.clean_cache(cache) database.clean()
database.act_all()
database.save() database.save()
log.debug(f"Running action") elif args.subcommand == RVCommand.list:
if args.action == "clean": cache = RVDatabase.load()
database.clean() if not cache:
else: raise FileNotFoundError("This command doesn't work without a cache yet.")
database.attempt_clean() for element in cache.elements:
for element in database.filter(args):
if args.action == "download":
element.preload()
elif args.action == "list":
print(element) print(element)
elif args.action in ("watch", "binge"):
element.watch()
if args.action == "watch":
break
database.attempt_clean()
database.save()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -33,7 +33,6 @@ audio_br_bi = 128000
quota_by = int(sys.argv[1]) quota_by = int(sys.argv[1])
in_file = sys.argv[2] in_file = sys.argv[2]
out_file = sys.argv[3] out_file = sys.argv[3]
filters = sys.argv[4:]
quota_bi = quota_by * 8 quota_bi = quota_by * 8
duration = duration_file(in_file) duration = duration_file(in_file)
@ -41,21 +40,15 @@ tot_br_bi = quota_bi / duration
video_br_bi = int(tot_br_bi - audio_br_bi) video_br_bi = int(tot_br_bi - audio_br_bi)
assert video_br_bi > 0, "Not even enough space for audio" assert video_br_bi > 0, "Not even enough space for audio"
cmd = ( cmd = [
[
"ffmpeg", "ffmpeg",
"-i", "-i",
in_file, in_file,
]
+ filters
+ [
"-b:v", "-b:v",
str(video_br_bi), str(video_br_bi),
"-b:a", "-b:a",
str(audio_br_bi), str(audio_br_bi),
out_file, out_file,
] ]
)
print(" ".join(cmd))
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)