chore: update dependencies and code cleaning

This commit is contained in:
Ales (Shagi) Zabala Alava 2024-03-08 09:38:58 +01:00
parent 1dcfce393b
commit 46fb9cff5a
12 changed files with 298 additions and 183 deletions

26
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,26 @@
repos:
# Using this mirror lets us use mypyc-compiled black, which is about 2x faster
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0
hooks:
- id: black
# It is recommended to specify the latest version of Python
# supported by your project here, or alternatively use
# pre-commit's default_language_version, see
# https://pre-commit.com/#top_level-default_language_version
language_version: python3.11
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.3.0
hooks:
# Run the linter.
- id: ruff
# - id: ruff-format We don't need this because we have black and isort.

View File

@ -1,5 +1,10 @@
"""
Filesystem based database of songs.
"""
import logging import logging
VERSION = "1.3" VERSION = "1.3"

View File

@ -1,16 +1,22 @@
"""
NegroMate command line.
"""
import argparse import argparse
import configparser import configparser
import logging import logging
import traceback
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
try: try:
from importlib import metadata from importlib import metadata
except ImportError: # Python <3.8 except ImportError: # Python <3.8
import importlib_metadata as metadata import importlib_metadata as metadata
CONFIG_FILE = '~/.negromate/config.ini' CONFIG_FILE = "~/.negromate/config.ini"
def main(): def main():
@ -64,7 +70,7 @@ def main():
sys.argv = args[:1] sys.argv = args[:1]
# Load commands from entry_point # Load commands from entry_point
entry_points = metadata.entry_points().get('negromate.commands', []) entry_points = metadata.entry_points().get("negromate.commands", [])
for entry_point in entry_points: for entry_point in entry_points:
try: try:
command = entry_point.load() command = entry_point.load()
@ -76,13 +82,13 @@ def main():
# Load initial configuration for commands # Load initial configuration for commands
initial_config = { initial_config = {
'global': { "global": {
'song_folder': '~/negro_mate/bideoak/', "song_folder": "~/negro_mate/bideoak/",
'lyrics_file': '~/negro_mate/libreto/libreto.pdf', "lyrics_file": "~/negro_mate/libreto/libreto.pdf",
} }
} }
for command in commands: for command in commands:
if hasattr(command, 'initial_config'): if hasattr(command, "initial_config"):
initial_config[command.name] = command.initial_config initial_config[command.name] = command.initial_config
# Load configuration # Load configuration
@ -92,9 +98,7 @@ def main():
# Build parser # Build parser
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument("-v", "--verbose", action="store_true", help="Display informational messages.")
'-v', '--verbose', action='store_true',
help="Display informational messages.")
parser.set_defaults(command=None) parser.set_defaults(command=None)
subparsers = parser.add_subparsers() subparsers = parser.add_subparsers()
for command in commands: for command in commands:

View File

@ -1,21 +1,27 @@
import sys """
Generate configuration file.
"""
from pathlib import Path from pathlib import Path
name = 'config'
help_text = 'Write the configuration' name = "config"
help_text = "Write the configuration"
initial_config = { initial_config = {
'file': '~/.negromate/config.ini', "file": "~/.negromate/config.ini",
} }
def options(parser, config, **kwargs): def options(parser, config, **kwargs):
parser.add_argument( parser.add_argument(
'-f', '--file', type=Path, "-f",
default=config['config']['file'], "--file",
help="Configuration file, defaults to {}".format( type=Path,
config['config']['file'])) default=config["config"]["file"],
help=f"Configuration file, defaults to {config['config']['file']}",
)
def run(args, config, **kwargs): def run(args, config, **kwargs):
with args.file.expanduser().open('w') as f: with args.file.expanduser().open("w") as f:
config.write(f) config.write(f)

View File

@ -1,62 +1,71 @@
"""
Load songs and generate missing files.
"""
from pathlib import Path from pathlib import Path
from ..loader import load_songs from ..loader import load_songs
name = 'songs'
help_text = 'Update song database' name = "songs"
help_text = "Update song database"
initial_config = { initial_config = {
'generate': 'yes', "generate": "yes",
'regenerate': 'no', "regenerate": "no",
'karaoke_template_file': '~/negro_mate/karaoke_templates/karaoke.ass', "karaoke_template_file": "~/negro_mate/karaoke_templates/karaoke.ass",
} }
def options(parser, config, **kwargs): def options(parser, config, **kwargs):
parser.add_argument( parser.add_argument(
'-s', '--song_folder', type=Path, "-s",
default=config['global']['song_folder'], "--song_folder",
help="Folder with the song database, defaults to {}".format( type=Path,
config['global']['song_folder'])) default=config["global"]["song_folder"],
help=f"Folder with the song database, defaults to {config['global']['song_folder']}",
)
parser.add_argument( parser.add_argument(
'-g', '--generate', action='store_const', const='yes', "-g",
default=config['songs']['generate'], "--generate",
help="Generate missing files, defaults to {}".format( action="store_const",
config['songs']['generate'])) const="yes",
default=config["songs"]["generate"],
help=f"Generate missing files, defaults to {config['songs']['generate']}",
)
parser.add_argument( parser.add_argument(
'-r', '--regenerate', action='store_const', const='yes', "-r",
default=config['songs']['regenerate'], "--regenerate",
help="Regenerate missing files, defaults to {}".format( action="store_const",
config['songs']['regenerate'])) const="yes",
default=config["songs"]["regenerate"],
help=f"Regenerate missing files, defaults to {config['songs']['regenerate']}",
)
parser.add_argument( parser.add_argument(
'-k', '--karaoke-template', type=Path, "-k",
default=config['songs']['karaoke_template_file'], "--karaoke-template",
help="Ass file with the karaoke template, defaults to {}".format( type=Path,
config['songs']['karaoke_template_file'])) default=config["songs"]["karaoke_template_file"],
help=f"Ass file with the karaoke template, defaults to {config['songs']['karaoke_template_file']}",
)
def run(args, **kwargs): def run(args, **kwargs):
generate = args.generate == 'yes' generate = args.generate == "yes"
regenerate = args.regenerate == 'yes' regenerate = args.regenerate == "yes"
songs, pending_songs = load_songs( songs, pending_songs = load_songs(
root_folder=args.song_folder.expanduser(), root_folder=args.song_folder.expanduser(),
generate=generate, regenerate=regenerate, generate=generate,
karaoke_template_file=args.karaoke_template.expanduser()) regenerate=regenerate,
karaoke_template_file=args.karaoke_template.expanduser(),
print(
"#######\n"
" Songs\n"
"#######"
) )
print("#######\n Songs\n#######")
for s in songs: for s in songs:
print(s.name) print(s.name)
print( print("###############\n Pending songs\n###############")
"###############\n"
" Pending songs\n"
"###############"
)
for s in pending_songs: for s in pending_songs:
print(s.name) print(s.name)
total_songs = len(songs) total_songs = len(songs)
songs_with_karaoke = len(list(s for s in songs if s.metadata['karaoke'])) songs_with_karaoke = len(list(s for s in songs if s.metadata["karaoke"]))
percent = int(songs_with_karaoke / total_songs * 100) if total_songs else 0 percent = int(songs_with_karaoke / total_songs * 100) if total_songs else 0
print("Total songs: {}. With karaoke: {} ({}%)".format(total_songs, songs_with_karaoke, percent)) print(f"Total songs: {total_songs}. With karaoke: {songs_with_karaoke} ({percent}%)")

View File

@ -1,22 +1,24 @@
"""
Generate song images
"""
from pathlib import Path from pathlib import Path
from negromate.songs.utils import generate_cover, generate_thumbnail from negromate.songs.utils import generate_cover, generate_thumbnail
name = 'thumbnail'
help_text = 'Generate cover and thumbnail for a video.' name = "thumbnail"
help_text = "Generate cover and thumbnail for a video."
def options(parser, config, **kwargs): def options(parser, **kwargs):
parser.add_argument( parser.add_argument("video", help="Video of the song.", type=Path)
'video', help="Video of the song.", type=Path) parser.add_argument("second", type=int, help="Take snapshot at this second.")
parser.add_argument(
'second', type=int,
help='Take snapshot at this second.')
def run(args, **kwargs): def run(args, **kwargs):
video = args.video video = args.video
cover = video.parent / 'cover.jpg' cover = video.parent / "cover.jpg"
thumbnail = video.parent / 'thumb.jpg' thumbnail = video.parent / "thumb.jpg"
generate_cover(video, cover, args.second) generate_cover(video, cover, args.second)
generate_thumbnail(cover, thumbnail) generate_thumbnail(cover, thumbnail)

View File

@ -7,10 +7,10 @@ import ass
@contextmanager @contextmanager
def Xephyr_env(display=":2", *args, **kwargs): def xephyr_env(display=":2", *args, **kwargs):
env = os.environ.copy() env = os.environ.copy()
xephyr = subprocess.Popen(["Xephyr", display], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) xephyr = subprocess.Popen(["Xephyr", display], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env['DISPLAY'] = display env["DISPLAY"] = display
try: try:
yield env yield env
finally: finally:
@ -21,7 +21,7 @@ def set_template(template_subtitles, orig_file, target_file=None):
if target_file is None: if target_file is None:
target_file = orig_file target_file = orig_file
with open(orig_file, 'r') as orig: with open(orig_file, "r") as orig:
subtitles = ass.parse(orig) subtitles = ass.parse(orig)
new_events = [] new_events = []
@ -29,15 +29,15 @@ def set_template(template_subtitles, orig_file, target_file=None):
new_events.append(dialogue) new_events.append(dialogue)
for dialogue in subtitles.events: for dialogue in subtitles.events:
if dialogue.effect.startswith('code'): if dialogue.effect.startswith("code"):
continue continue
if dialogue.effect.startswith('template'): if dialogue.effect.startswith("template"):
continue continue
new_events.append(dialogue) new_events.append(dialogue)
subtitles.events = new_events subtitles.events = new_events
with open(target_file, 'w', encoding='utf-8-sig') as target: with open(target_file, "w", encoding="utf-8-sig") as target:
subtitles.dump_file(target) subtitles.dump_file(target)
@ -74,19 +74,35 @@ def apply_template(subtitles, env):
def update_karaoke_songs(songs, template_file, regenerate=False): def update_karaoke_songs(songs, template_file, regenerate=False):
from negromate.songs.utils import needs_change from negromate.songs.utils import needs_change
with open(template_file, 'r') as template: with open(template_file, "r") as template:
template_subtitles = ass.parse(template) template_subtitles = ass.parse(template)
with Xephyr_env() as env: with xephyr_env() as env:
for song in songs: for song in songs:
if song.metadata.get('karaoke'): if song.metadata.get("karaoke"):
target = song.path / "{}.karaoke.ass".format(song.path.name) target = song.path / "{}.karaoke.ass".format(song.path.name)
if regenerate or needs_change(target, (song.ass, template_file)): if regenerate or needs_change(target, (song.ass, template_file)):
set_template( set_template(
template_subtitles=template_subtitles, template_subtitles=template_subtitles, orig_file=str(song.ass), target_file=str(target)
orig_file=str(song.ass),
target_file=str(target)
) )
time.sleep(2) time.sleep(2)
apply_template(str(target), env) apply_template(str(target), env)
time.sleep(2) time.sleep(2)
def generate_karaoke_ass(template_file, orig_file, target_file):
"""
Apply ass template to the subtitle file to render animations.
"""
with open(template_file, "r") as template:
template_subtitles = ass.parse(template)
with xephyr_env() as env:
set_template(
template_subtitles=template_subtitles,
orig_file=orig_file,
target_file=target_file,
)
time.sleep(2)
apply_template(target_file, env)
time.sleep(2)

View File

@ -1,13 +1,22 @@
"""
Load songs from the root folder.
"""
import json import json
import asstosrt import asstosrt
import webvtt import webvtt
from .utils import needs_change, generate_cover, generate_thumbnail, generate_karaoke_ass
from . import logger from . import logger
from .karaoke_templates import generate_karaoke_ass
from .utils import generate_cover, generate_thumbnail, needs_change
class Song: class Song:
"""
Python representation of a song.
"""
def __init__(self, path, root): def __init__(self, path, root):
self.name = path.name self.name = path.name
self.metadata = None self.metadata = None
@ -28,109 +37,129 @@ class Song:
self.search_media() self.search_media()
def search_media(self): def search_media(self):
"""
Initialize song attributes.
"""
for entry in self.path.iterdir(): for entry in self.path.iterdir():
if entry.name == 'metadata.json': if entry.name == "metadata.json":
with entry.open('r') as metadatafile: with entry.open("r") as metadatafile:
self.metadata = json.load(metadatafile) self.metadata = json.load(metadatafile)
if 'name' in self.metadata: self.name = self.metadata.get("name", self.path.name)
self.name = self.metadata['name'] self.original = self.metadata.get("original", None)
if 'original' in self.metadata: self.author = self.metadata.get("author", None)
self.original = self.metadata['original'] self.date = self.metadata.get("date", None)
if 'author' in self.metadata: elif entry.name.endswith("mp4"):
self.author = self.metadata['author']
if 'date' in self.metadata:
self.date = self.metadata['date']
elif entry.name.endswith('mp4'):
self.video = entry self.video = entry
self.video_type = 'video/mp4' self.video_type = "video/mp4"
self.files.append(entry) self.files.append(entry)
elif entry.name.endswith('webm'): elif entry.name.endswith("webm"):
self.video = entry self.video = entry
self.video_type = 'video/webm' self.video_type = "video/webm"
self.files.append(entry) self.files.append(entry)
elif entry.name.endswith('ogv'): elif entry.name.endswith("ogv"):
self.video = entry self.video = entry
self.video_type = 'video/ogg' self.video_type = "video/ogg"
self.files.append(entry) self.files.append(entry)
elif entry.name.endswith('vtt'): elif entry.name.endswith("vtt"):
self.vtt = entry self.vtt = entry
elif entry.name == "{}.srt".format(self.path.name): elif entry.name == f"{self.path.name}.srt":
self.srt = entry self.srt = entry
self.files.append(entry) self.files.append(entry)
elif entry.name == "{}.karaoke.ass".format(self.path.name): elif entry.name == f"{self.path.name}.karaoke.ass":
self.karaoke_ass = entry self.karaoke_ass = entry
self.files.append(entry) self.files.append(entry)
elif entry.name == "{}.ass".format(self.path.name): elif entry.name == f"{self.path.name}.ass":
self.ass = entry self.ass = entry
self.files.append(entry) self.files.append(entry)
elif entry.name == 'thumb.jpg': elif entry.name == "thumb.jpg":
self.thumbnail = entry self.thumbnail = entry
elif entry.name == 'cover.jpg': elif entry.name == "cover.jpg":
self.cover = entry self.cover = entry
elif entry.name == 'index.html': elif entry.name == "index.html":
continue continue
else: else:
self.files.append(entry) self.files.append(entry)
def generate_missing(self, regenerate=False, karaoke_template_file=None): def generate_missing(self, regenerate=False, karaoke_template_file=None):
srt_ = self.path / "{}.srt".format(self.path.name) """
Generate missing files, if they can be derived from another one.
"""
srt_ = self.path / f"{self.path.name}.srt"
if regenerate or needs_change(srt_, (self.ass,)): if regenerate or needs_change(srt_, (self.ass,)):
logger.info("generating {}".format(srt_)) logger.info("generating %s", str(srt_))
self.srt = srt_ self.srt = srt_
with self.ass.open('r') as assfile, self.srt.open('w') as srtfile: with self.ass.open("r") as assfile, self.srt.open("w") as srtfile:
srtfile.write(asstosrt.convert(assfile)) srtfile.write(asstosrt.convert(assfile))
self.files.append(self.srt) self.files.append(self.srt)
vtt = self.path / "{}.vtt".format(self.path.name) vtt = self.path / "{self.path.name}.vtt"
if regenerate or needs_change(vtt, (self.srt,)): if regenerate or needs_change(vtt, (self.srt,)):
logger.info("generating {}".format(vtt)) logger.info("generating %s", str(vtt))
self.vtt = vtt self.vtt = vtt
webvtt.from_srt(str(self.srt.absolute())).save(str(self.vtt.absolute())) webvtt.from_srt(str(self.srt.absolute())).save(str(self.vtt.absolute()))
cover = self.path / "cover.jpg" cover = self.path / "cover.jpg"
if regenerate or needs_change(cover, (self.video,)): if regenerate or needs_change(cover, (self.video,)):
logger.info("generating {}".format(cover)) logger.info("generating %s", str(cover))
self.cover = cover self.cover = cover
generate_cover(self.video, self.cover) generate_cover(self.video, self.cover)
thumbnail = self.path / "thumb.jpg" thumbnail = self.path / "thumb.jpg"
if regenerate or needs_change(thumbnail, (self.cover,)): if regenerate or needs_change(thumbnail, (self.cover,)):
logger.info("generating {}".format(thumbnail)) logger.info("generating %s", str(thumbnail))
self.thumbnail = thumbnail self.thumbnail = thumbnail
generate_thumbnail(self.cover, self.thumbnail) generate_thumbnail(self.cover, self.thumbnail)
karaoke_ass = self.path / "{}.karaoke.ass".format(self.path.name) karaoke_ass = self.path / f"{self.path.name}.karaoke.ass"
karaoke_requirements = ( karaoke_requirements = (
self.metadata.get('karaoke', False), self.metadata.get("karaoke", False),
regenerate or needs_change(karaoke_ass, (self.ass, karaoke_template_file)), regenerate or needs_change(karaoke_ass, (self.ass, karaoke_template_file)),
) )
if all(karaoke_requirements): if all(karaoke_requirements):
logger.info("generating {}".format(karaoke_ass)) logger.info("generating %s", str(karaoke_ass))
self.karaoke_ass = karaoke_ass self.karaoke_ass = karaoke_ass
generate_karaoke_ass(str(karaoke_template_file), str(self.ass), str(karaoke_ass)) generate_karaoke_ass(str(karaoke_template_file), str(self.ass), str(karaoke_ass))
@property @property
def has_subtitles(self): def has_subtitles(self):
"""
True if the song has any type of subtitles.
"""
return self.ass or self.srt or self.vtt return self.ass or self.srt or self.vtt
@property @property
def publish(self): def publish(self):
"""
True if the song can be published.
"""
return self.video and self.has_subtitles return self.video and self.has_subtitles
@property @property
def pending(self): def pending(self):
"""
True if the song has a video and ass subtitles.
"""
finished = self.ass and self.video finished = self.ass and self.video
return not finished return not finished
def load_songs(root_folder, generate=True, regenerate=False, karaoke_template_file=None): def load_songs(root_folder, generate=True, regenerate=False, karaoke_template_file=None):
"""
Load songs from root_folder.
If generate is True missing files will be generated.
If regenerate is True, the files will be generated again, even if they source has not changed.
karaoke_template_file can be a path to a ass file with the code to generate subtitle animations.
"""
songs = [] songs = []
pending_songs = [] pending_songs = []
for entry in root_folder.iterdir(): for entry in root_folder.iterdir():
if entry.name in ['static', 'playlist', 'home', 'todo']: if entry.name in ["static", "playlist", "home", "todo"]:
continue continue
if entry.is_dir() and (entry / 'metadata.json').exists(): if entry.is_dir() and (entry / "metadata.json").exists():
logger.info("building {}".format(entry.name)) logger.info("building %s", str(entry.name))
try: try:
song = Song(entry, root_folder) song = Song(entry, root_folder)
if generate: if generate:

View File

@ -1,19 +1,19 @@
import subprocess """
import ass Helper functions.
import time """
from . import karaoke_templates import subprocess
def needs_change(destination, dependencies): def needs_change(destination, dependencies):
"""
Checks if the destination file is older than its dependencies.
"""
last_dependency_change = 0 last_dependency_change = 0
for dependency in dependencies: for dependency in dependencies:
if dependency is None: if dependency is None:
return False return False
last_dependency_change = max( last_dependency_change = max(last_dependency_change, dependency.lstat().st_mtime)
last_dependency_change,
dependency.lstat().st_mtime
)
if not destination.exists(): if not destination.exists():
return True return True
@ -22,41 +22,39 @@ def needs_change(destination, dependencies):
def generate_cover(video, cover, second=2): def generate_cover(video, cover, second=2):
"""
Take a snapshot of the video to create a cover file.
"""
command = [ command = [
'ffmpeg', "ffmpeg",
'-loglevel', 'quiet', "-loglevel",
'-i', str(video.absolute()), "quiet",
'-vcodec', 'mjpeg', "-i",
'-vframes', '1', str(video.absolute()),
'-an', "-vcodec",
'-f', 'rawvideo', "mjpeg",
'-ss', str(second), "-vframes",
'-y', "1",
"-an",
"-f",
"rawvideo",
"-ss",
str(second),
"-y",
str(cover.absolute()), str(cover.absolute()),
] ]
subprocess.check_call(command) subprocess.check_call(command)
def generate_thumbnail(cover, thumbnail, geometry="200x200"): def generate_thumbnail(cover, thumbnail, geometry="200x200"):
"""
Generate a reduced image of the cover.
"""
command = [ command = [
'convert', "convert",
str(cover.absolute()), str(cover.absolute()),
'-resize', geometry, "-resize",
geometry,
str(thumbnail.absolute()), str(thumbnail.absolute()),
] ]
subprocess.check_call(command) subprocess.check_call(command)
def generate_karaoke_ass(template_file, orig_file, target_file):
with open(template_file, 'r') as template:
template_subtitles = ass.parse(template)
with karaoke_templates.Xephyr_env() as env:
karaoke_templates.set_template(
template_subtitles=template_subtitles,
orig_file=orig_file,
target_file=target_file,
)
time.sleep(2)
karaoke_templates.apply_template(target_file, env)
time.sleep(2)

View File

@ -1,3 +1,23 @@
[build-system] [build-system]
requires = ["setuptools", "wheel"] requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black]
line_length = 120
[tool.isort]
profile = "black"
lines_after_imports = 2
[tool.pylint.'MESSAGES CONTROL']
max-line-length = 120
disable = "invalid-name, unused-wildcard-import, wildcard-import"
[tool.ruff]
line-length = 120
exclude = [
"build",
]
include = ["negromate/*"]
fix = false
force-exclude = true

View File

@ -1,3 +1,3 @@
webvtt-py webvtt-py
asstosrt==0.1.6 asstosrt==0.1.6
srt==1.6.0 srt==3.5.3

View File

@ -25,7 +25,7 @@ install_requires =
importlib_metadata importlib_metadata
webvtt-py webvtt-py
asstosrt ==0.1.6 asstosrt ==0.1.6
srt ==3.5.2 srt ==3.5.3
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =