Compare commits
19 Commits
|
@ -1 +1,3 @@
|
||||||
negromate.songs.egg-info
|
/negromate.songs.egg-info
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
|
@ -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.
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5
|
||||||
|
|
||||||
|
* Update readme and fix dependencies
|
||||||
|
* Fix typo
|
||||||
|
|
||||||
|
## 1.4
|
||||||
|
|
||||||
|
* Update dependencies and code cleaning
|
||||||
|
|
||||||
|
## 1.3
|
||||||
|
|
||||||
|
* New property for songs: has_subtitles
|
||||||
|
* Upgrade dependencies
|
||||||
|
* Add songs without ass file as pending
|
||||||
|
* Song folders must have a metadata.json file
|
||||||
|
|
||||||
|
## 1.2
|
||||||
|
|
||||||
|
* Get karaoke ass generator from negromate.web
|
||||||
|
* Add date to songs
|
||||||
|
* Convert to namespace package
|
||||||
|
* Add verbose flag
|
||||||
|
* Use configuration file for commands defaults
|
||||||
|
|
||||||
|
## 1.1
|
||||||
|
|
||||||
|
* Start with generic negromate command with entry points for other modules
|
||||||
|
* Move image generating code to utils
|
||||||
|
* Migrate setup to pep517
|
58
README.md
58
README.md
|
@ -1,4 +1,58 @@
|
||||||
Negro Mate Songs
|
Negro Mate Songs
|
||||||
==================
|
================
|
||||||
|
[Negro Mate](https://negromate.rocks)'s song database library. Not very versatile without two another packages:
|
||||||
|
|
||||||
NegroMate's song database library.
|
* [negromate.web](https://pypi.org/project/negromate.web/): Static html compiler for the web of Negro Mage
|
||||||
|
* [negromate.karaoke](https://pypi.org/project/negromate.karaoke/): Simple karaoke video browser
|
||||||
|
|
||||||
|
Provides the base `negromate` command to interact with a song repository.
|
||||||
|
|
||||||
|
negromate config: Write the configuration file
|
||||||
|
negromate songs: Update song database
|
||||||
|
negromate thumbnail: Generate cover and thumbnail for a song
|
||||||
|
|
||||||
|
Needs the following packages installed:
|
||||||
|
|
||||||
|
* imagemagick
|
||||||
|
* ffmpeg
|
||||||
|
|
||||||
|
Karaoke subtitle generation is currently broken, it worked with these
|
||||||
|
packages on older systems:
|
||||||
|
|
||||||
|
* xdotool
|
||||||
|
* aegisub
|
||||||
|
* xserver-xephyr
|
||||||
|
|
||||||
|
|
||||||
|
## Song library structure
|
||||||
|
|
||||||
|
Each song is a folder containing at least a video file, a subtitles file and a metadata file:
|
||||||
|
|
||||||
|
song_name/
|
||||||
|
├── song_name.ass
|
||||||
|
├── song_name.mp4
|
||||||
|
└── metadata.json
|
||||||
|
|
||||||
|
Video and subtitles file has to be named like the folder. Supported subtitles:
|
||||||
|
|
||||||
|
* ass (prefered)
|
||||||
|
* src
|
||||||
|
* vtt
|
||||||
|
|
||||||
|
### The metadata json file
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Song name",
|
||||||
|
"original": "Original song, Original author",
|
||||||
|
"karaoke": true,
|
||||||
|
"date": "XXXX-XX-XX"
|
||||||
|
"author": "Author of the karaoke"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
* `name`: used as the title of the song.
|
||||||
|
* `original`: for giving credit to original authors.
|
||||||
|
* `karaoke`: if `true`, the ass subtitles file has karaoke level subtitles (syllable level timing)
|
||||||
|
and automatik karaoke subtitles can be generated.
|
||||||
|
* `date`: approx. creation date of this version
|
||||||
|
* `author`: not required, used for givin credits to the author when it's different from the web authors.
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
|
"""
|
||||||
|
Filesystem based database of songs.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = "1.5"
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("negromate.songs")
|
logger = logging.getLogger("negromate.songs")
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .loader import load_songs
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
songs, pending_songs = load_songs(Path(sys.argv[1]))
|
|
||||||
print(
|
|
||||||
"#######\n"
|
|
||||||
" Songs\n"
|
|
||||||
"#######"
|
|
||||||
)
|
|
||||||
for s in songs:
|
|
||||||
print(s.name)
|
|
||||||
print(
|
|
||||||
"###############\n"
|
|
||||||
" Pending songs\n"
|
|
||||||
"###############"
|
|
||||||
)
|
|
||||||
for s in pending_songs:
|
|
||||||
print(s.name)
|
|
||||||
total_songs = len(songs)
|
|
||||||
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
|
|
||||||
print("Total songs: {}. With karaoke: {} ({}%)".format(total_songs, songs_with_karaoke, percent))
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""
|
||||||
|
NegroMate command line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from importlib import metadata
|
||||||
|
except ImportError: # Python <3.8
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_FILE = "~/.negromate/config.ini"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Build parser for all the commands and launch appropiate command.
|
||||||
|
|
||||||
|
Each command must be a module with at least the following members:
|
||||||
|
|
||||||
|
* name: String with the command name. Will be used for
|
||||||
|
argparse subcommand.
|
||||||
|
* help_text: String with the help text.
|
||||||
|
* initial_config: Dict for initial configuration of commands.
|
||||||
|
* options: Function to build the parser of the command. Takes
|
||||||
|
two parametters, the argparser parser instance for this
|
||||||
|
subcommand and the ConfigParser instance with all the
|
||||||
|
configuration.
|
||||||
|
* run: Function that runs the actual command. Takes two
|
||||||
|
parametters, the argparse Namespace with the arguments and
|
||||||
|
the ConfigParser with all the configuration.
|
||||||
|
|
||||||
|
Minimal module example:
|
||||||
|
|
||||||
|
# hello_world.py
|
||||||
|
name = 'hello'
|
||||||
|
help_text = 'Sample command'
|
||||||
|
initial_config = {
|
||||||
|
'who': 'World',
|
||||||
|
}
|
||||||
|
|
||||||
|
def options(parser, config, **kwargs):
|
||||||
|
parser.add_argument(
|
||||||
|
'-w', '--who', default=config['hello']['who'],
|
||||||
|
help="Who to say hello, defaults to '{}'".format(config['hello']['who'])
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(args, **kwargs):
|
||||||
|
print("Hello {}".format(args.who))
|
||||||
|
|
||||||
|
To add more commands to negromate register 'negromate.commands'
|
||||||
|
entry point in setup.cfg. For example:
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
negromate.commands =
|
||||||
|
hello = negromate.web.commands.hello_world
|
||||||
|
|
||||||
|
"""
|
||||||
|
commands = []
|
||||||
|
# clean sys.argv for any module imported here
|
||||||
|
# Yes Kivy, I'm looking at you
|
||||||
|
args = sys.argv.copy()
|
||||||
|
sys.argv = args[:1]
|
||||||
|
|
||||||
|
# Load commands from entry_point
|
||||||
|
entry_points = metadata.entry_points().get("negromate.commands", [])
|
||||||
|
for entry_point in entry_points:
|
||||||
|
try:
|
||||||
|
command = entry_point.load()
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
print(e)
|
||||||
|
continue
|
||||||
|
commands.append(command)
|
||||||
|
|
||||||
|
# Load initial configuration for commands
|
||||||
|
initial_config = {
|
||||||
|
"global": {
|
||||||
|
"song_folder": "~/negro_mate/bideoak/",
|
||||||
|
"lyrics_file": "~/negro_mate/libreto/libreto.pdf",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for command in commands:
|
||||||
|
if hasattr(command, "initial_config"):
|
||||||
|
initial_config[command.name] = command.initial_config
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read_dict(initial_config)
|
||||||
|
config.read(Path(CONFIG_FILE).expanduser())
|
||||||
|
|
||||||
|
# Build parser
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true", help="Display informational messages.")
|
||||||
|
parser.set_defaults(command=None)
|
||||||
|
subparsers = parser.add_subparsers()
|
||||||
|
for command in commands:
|
||||||
|
command_parser = subparsers.add_parser(command.name, help=command.help_text)
|
||||||
|
command_parser.set_defaults(command=command.name)
|
||||||
|
command.options(parser=command_parser, config=config)
|
||||||
|
|
||||||
|
# Run command
|
||||||
|
args = parser.parse_args(args[1:])
|
||||||
|
if args.verbose:
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
if args.command is None:
|
||||||
|
parser.print_usage()
|
||||||
|
else:
|
||||||
|
for command in commands:
|
||||||
|
if args.command == command.name:
|
||||||
|
command.run(args=args, config=config)
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""
|
||||||
|
Generate configuration file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
name = "config"
|
||||||
|
help_text = "Write the configuration"
|
||||||
|
initial_config = {
|
||||||
|
"file": "~/.negromate/config.ini",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def options(parser, config, **kwargs):
|
||||||
|
parser.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
default=config["config"]["file"],
|
||||||
|
help=f"Configuration file, defaults to {config['config']['file']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, config, **kwargs):
|
||||||
|
with args.file.expanduser().open("w") as f:
|
||||||
|
config.write(f)
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""
|
||||||
|
Load songs and generate missing files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..loader import load_songs
|
||||||
|
|
||||||
|
|
||||||
|
name = "songs"
|
||||||
|
help_text = "Update song database"
|
||||||
|
initial_config = {
|
||||||
|
"generate": "yes",
|
||||||
|
"regenerate": "no",
|
||||||
|
"karaoke_template_file": "~/negro_mate/karaoke_templates/karaoke.ass",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def options(parser, config, **kwargs):
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--song_folder",
|
||||||
|
type=Path,
|
||||||
|
default=config["global"]["song_folder"],
|
||||||
|
help=f"Folder with the song database, defaults to {config['global']['song_folder']}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-g",
|
||||||
|
"--generate",
|
||||||
|
action="store_const",
|
||||||
|
const="yes",
|
||||||
|
default=config["songs"]["generate"],
|
||||||
|
help=f"Generate missing files, defaults to {config['songs']['generate']}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-r",
|
||||||
|
"--regenerate",
|
||||||
|
action="store_const",
|
||||||
|
const="yes",
|
||||||
|
default=config["songs"]["regenerate"],
|
||||||
|
help=f"Regenerate missing files, defaults to {config['songs']['regenerate']}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--karaoke-template",
|
||||||
|
type=Path,
|
||||||
|
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):
|
||||||
|
generate = args.generate == "yes"
|
||||||
|
regenerate = args.regenerate == "yes"
|
||||||
|
songs, pending_songs = load_songs(
|
||||||
|
root_folder=args.song_folder.expanduser(),
|
||||||
|
generate=generate,
|
||||||
|
regenerate=regenerate,
|
||||||
|
karaoke_template_file=args.karaoke_template.expanduser(),
|
||||||
|
)
|
||||||
|
|
||||||
|
print("#######\n Songs\n#######")
|
||||||
|
for s in songs:
|
||||||
|
print(s.name)
|
||||||
|
print("###############\n Pending songs\n###############")
|
||||||
|
for s in pending_songs:
|
||||||
|
print(s.name)
|
||||||
|
total_songs = len(songs)
|
||||||
|
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
|
||||||
|
print(f"Total songs: {total_songs}. With karaoke: {songs_with_karaoke} ({percent}%)")
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""
|
||||||
|
Generate song images
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from negromate.songs.utils import generate_cover, generate_thumbnail
|
||||||
|
|
||||||
|
|
||||||
|
name = "thumbnail"
|
||||||
|
help_text = "Generate cover and thumbnail for a video."
|
||||||
|
|
||||||
|
|
||||||
|
def options(parser, **kwargs):
|
||||||
|
parser.add_argument("video", help="Video of the song.", type=Path)
|
||||||
|
parser.add_argument("second", type=int, help="Take snapshot at this second.")
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, **kwargs):
|
||||||
|
video = args.video
|
||||||
|
cover = video.parent / "cover.jpg"
|
||||||
|
thumbnail = video.parent / "thumb.jpg"
|
||||||
|
generate_cover(video, cover, args.second)
|
||||||
|
generate_thumbnail(cover, thumbnail)
|
|
@ -0,0 +1,108 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import ass
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def xephyr_env(display=":2", *args, **kwargs):
|
||||||
|
env = os.environ.copy()
|
||||||
|
xephyr = subprocess.Popen(["Xephyr", display], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
env["DISPLAY"] = display
|
||||||
|
try:
|
||||||
|
yield env
|
||||||
|
finally:
|
||||||
|
xephyr.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def set_template(template_subtitles, orig_file, target_file=None):
|
||||||
|
if target_file is None:
|
||||||
|
target_file = orig_file
|
||||||
|
|
||||||
|
with open(orig_file, "r") as orig:
|
||||||
|
subtitles = ass.parse(orig)
|
||||||
|
|
||||||
|
new_events = []
|
||||||
|
for dialogue in template_subtitles.events:
|
||||||
|
new_events.append(dialogue)
|
||||||
|
|
||||||
|
for dialogue in subtitles.events:
|
||||||
|
if dialogue.effect.startswith("code"):
|
||||||
|
continue
|
||||||
|
if dialogue.effect.startswith("template"):
|
||||||
|
continue
|
||||||
|
new_events.append(dialogue)
|
||||||
|
|
||||||
|
subtitles.events = new_events
|
||||||
|
|
||||||
|
with open(target_file, "w", encoding="utf-8-sig") as target:
|
||||||
|
subtitles.dump_file(target)
|
||||||
|
|
||||||
|
|
||||||
|
def run(command, env, wait=None):
|
||||||
|
subprocess.Popen(
|
||||||
|
command,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if wait is not None:
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_template(subtitles, env):
|
||||||
|
run(["aegisub-3.2", subtitles], env=env, wait=2)
|
||||||
|
|
||||||
|
# Si pide confirmación para cargar video ignorar el popup
|
||||||
|
run(["xdotool", "key", "Escape"], env=env, wait=0.1)
|
||||||
|
|
||||||
|
# abrir el menú de automatización, bajar dos y darle a aplicar template
|
||||||
|
run(["xdotool", "key", "alt+u"], env=env, wait=0.1)
|
||||||
|
run(["xdotool", "key", "Down"], env=env, wait=0.1)
|
||||||
|
run(["xdotool", "key", "Down"], env=env, wait=0.1)
|
||||||
|
run(["xdotool", "key", "Return"], env=env, wait=2)
|
||||||
|
|
||||||
|
# guardar
|
||||||
|
run(["xdotool", "key", "ctrl+s"], env=env)
|
||||||
|
|
||||||
|
# cerrar programa
|
||||||
|
run(["xdotool", "key", "ctrl+q"], env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def update_karaoke_songs(songs, template_file, regenerate=False):
|
||||||
|
from negromate.songs.utils import needs_change
|
||||||
|
|
||||||
|
with open(template_file, "r") as template:
|
||||||
|
template_subtitles = ass.parse(template)
|
||||||
|
|
||||||
|
with xephyr_env() as env:
|
||||||
|
for song in songs:
|
||||||
|
if song.metadata.get("karaoke"):
|
||||||
|
target = song.path / "{}.karaoke.ass".format(song.path.name)
|
||||||
|
if regenerate or needs_change(target, (song.ass, template_file)):
|
||||||
|
set_template(
|
||||||
|
template_subtitles=template_subtitles, orig_file=str(song.ass), target_file=str(target)
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
apply_template(str(target), env)
|
||||||
|
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)
|
|
@ -1,22 +1,28 @@
|
||||||
|
"""
|
||||||
|
Load songs from the root folder.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import asstosrt
|
import asstosrt
|
||||||
import srt
|
|
||||||
import webvtt
|
import webvtt
|
||||||
|
|
||||||
from .utils import needs_change
|
|
||||||
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:
|
||||||
THUMBNAIL_GEOMETRY = '200x200'
|
"""
|
||||||
|
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
|
||||||
self.original = None
|
self.original = None
|
||||||
self.author = None
|
self.author = None
|
||||||
|
self.date = None
|
||||||
self.path = path
|
self.path = path
|
||||||
self.root = root
|
self.root = root
|
||||||
self.video = None
|
self.video = None
|
||||||
|
@ -31,110 +37,139 @@ 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']
|
|
||||||
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)
|
||||||
|
|
||||||
srt = self.path / "{}.srt".format(self.path.name)
|
def generate_missing(self, regenerate=False, karaoke_template_file=None):
|
||||||
if needs_change(srt, (self.ass,)):
|
"""
|
||||||
self.srt = srt
|
Generate missing files, if they can be derived from another one.
|
||||||
with self.ass.open('r') as assfile, self.srt.open('w') as srtfile:
|
"""
|
||||||
|
srt_ = self.path / f"{self.path.name}.srt"
|
||||||
|
if regenerate or needs_change(srt_, (self.ass,)):
|
||||||
|
logger.info("generating %s", str(srt_))
|
||||||
|
self.srt = srt_
|
||||||
|
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 / f"{self.path.name}.vtt"
|
||||||
if needs_change(vtt, (self.srt,)):
|
if regenerate or needs_change(vtt, (self.srt,)):
|
||||||
|
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 needs_change(cover, (self.video,)):
|
if regenerate or needs_change(cover, (self.video,)):
|
||||||
|
logger.info("generating %s", str(cover))
|
||||||
self.cover = cover
|
self.cover = cover
|
||||||
command = [
|
generate_cover(self.video, self.cover)
|
||||||
'ffmpeg',
|
|
||||||
'-loglevel', 'quiet',
|
|
||||||
'-i', str(self.video.absolute()),
|
|
||||||
'-vcodec', 'mjpeg',
|
|
||||||
'-vframes', '1',
|
|
||||||
'-an',
|
|
||||||
'-f', 'rawvideo',
|
|
||||||
'-ss', '2',
|
|
||||||
'-y',
|
|
||||||
str(self.cover.absolute()),
|
|
||||||
]
|
|
||||||
subprocess.check_call(command)
|
|
||||||
|
|
||||||
thumbnail = self.path / "thumb.jpg"
|
thumbnail = self.path / "thumb.jpg"
|
||||||
if needs_change(thumbnail, (self.cover,)):
|
if regenerate or needs_change(thumbnail, (self.cover,)):
|
||||||
|
logger.info("generating %s", str(thumbnail))
|
||||||
self.thumbnail = thumbnail
|
self.thumbnail = thumbnail
|
||||||
subprocess.check_call([
|
generate_thumbnail(self.cover, self.thumbnail)
|
||||||
'convert',
|
|
||||||
str(self.cover.absolute()),
|
karaoke_ass = self.path / f"{self.path.name}.karaoke.ass"
|
||||||
'-resize', self.THUMBNAIL_GEOMETRY,
|
karaoke_requirements = (
|
||||||
str(self.thumbnail.absolute()),
|
self.metadata.get("karaoke", False),
|
||||||
])
|
regenerate or needs_change(karaoke_ass, (self.ass, karaoke_template_file)),
|
||||||
|
)
|
||||||
|
if all(karaoke_requirements):
|
||||||
|
logger.info("generating %s", str(karaoke_ass))
|
||||||
|
self.karaoke_ass = karaoke_ass
|
||||||
|
generate_karaoke_ass(str(karaoke_template_file), str(self.ass), str(karaoke_ass))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_subtitles(self):
|
||||||
|
"""
|
||||||
|
True if the song has any type of subtitles.
|
||||||
|
"""
|
||||||
|
return self.ass or self.srt or self.vtt
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def publish(self):
|
def publish(self):
|
||||||
has_subtitles = self.ass or self.srt or self.vtt
|
"""
|
||||||
has_video = self.video
|
True if the song can be published.
|
||||||
return has_video and has_subtitles
|
"""
|
||||||
|
return self.video and self.has_subtitles
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self):
|
||||||
|
"""
|
||||||
|
True if the song has a video and ass subtitles.
|
||||||
|
"""
|
||||||
|
finished = self.ass and self.video
|
||||||
|
return not finished
|
||||||
|
|
||||||
|
|
||||||
def load_songs(root_folder):
|
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():
|
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:
|
||||||
|
song.generate_missing(regenerate, karaoke_template_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error: %s", e)
|
logger.error("Error: %s", e)
|
||||||
continue
|
continue
|
||||||
if song.publish:
|
if song.publish:
|
||||||
songs.append(song)
|
songs.append(song)
|
||||||
else:
|
if song.pending:
|
||||||
pending_songs.append(song)
|
pending_songs.append(song)
|
||||||
|
|
||||||
songs.sort(key=lambda a: a.name)
|
songs.sort(key=lambda a: a.name)
|
||||||
|
|
|
@ -1,14 +1,60 @@
|
||||||
|
"""
|
||||||
|
Helper functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
return destination.lstat().st_mtime < last_dependency_change
|
return destination.lstat().st_mtime < last_dependency_change
|
||||||
|
|
||||||
|
|
||||||
|
def generate_cover(video, cover, second=2):
|
||||||
|
"""
|
||||||
|
Take a snapshot of the video to create a cover file.
|
||||||
|
"""
|
||||||
|
command = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"quiet",
|
||||||
|
"-i",
|
||||||
|
str(video.absolute()),
|
||||||
|
"-vcodec",
|
||||||
|
"mjpeg",
|
||||||
|
"-vframes",
|
||||||
|
"1",
|
||||||
|
"-an",
|
||||||
|
"-f",
|
||||||
|
"rawvideo",
|
||||||
|
"-ss",
|
||||||
|
str(second),
|
||||||
|
"-y",
|
||||||
|
str(cover.absolute()),
|
||||||
|
]
|
||||||
|
subprocess.check_call(command)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(cover, thumbnail, geometry="200x200"):
|
||||||
|
"""
|
||||||
|
Generate a reduced image of the cover.
|
||||||
|
"""
|
||||||
|
command = [
|
||||||
|
"convert",
|
||||||
|
str(cover.absolute()),
|
||||||
|
"-resize",
|
||||||
|
geometry,
|
||||||
|
str(thumbnail.absolute()),
|
||||||
|
]
|
||||||
|
subprocess.check_call(command)
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "wheel"]
|
||||||
|
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
|
|
@ -1,3 +1,4 @@
|
||||||
webvtt-py
|
ass==0.5.2
|
||||||
|
webvtt-py==0.4.6
|
||||||
asstosrt==0.1.6
|
asstosrt==0.1.6
|
||||||
srt==1.6.0
|
srt==3.5.3
|
||||||
|
|
35
setup.cfg
35
setup.cfg
|
@ -1,7 +1,40 @@
|
||||||
[metadata]
|
[metadata]
|
||||||
# This includes the license file(s) in the wheel.
|
# This includes the license file(s) in the wheel.
|
||||||
# https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file
|
# https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file
|
||||||
license_files = LICENSE.txt
|
name = negromate.songs
|
||||||
|
version = attr: negromate.songs.VERSION
|
||||||
|
license-file = LICENSE.txt
|
||||||
|
author = Ales (Shagi) Zabala Alava
|
||||||
|
author_email = shagi@gisa-elkartea.org
|
||||||
|
url = https://negromate.rocks
|
||||||
|
description = NegroMate karaoke song database library
|
||||||
|
long_description_content_type = text/markdown
|
||||||
|
long_description = file: README.md, CHANGELOG.md
|
||||||
|
license = GPLv3
|
||||||
|
classifiers =
|
||||||
|
Development Status :: 5 - Production/Stable
|
||||||
|
License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
||||||
|
Programming Language :: Python :: 3
|
||||||
|
Topic :: Games/Entertainment
|
||||||
|
|
||||||
|
[options]
|
||||||
|
packages = find_namespace:
|
||||||
|
zip_safe = true
|
||||||
|
python_requires = >= 3.4
|
||||||
|
install_requires =
|
||||||
|
importlib_metadata
|
||||||
|
webvtt-py
|
||||||
|
asstosrt ==0.1.6
|
||||||
|
srt ==3.5.3
|
||||||
|
ass ==0.5.2
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
negromate = negromate.songs.commands:main
|
||||||
|
negromate.commands =
|
||||||
|
songs = negromate.songs.commands.songs
|
||||||
|
config = negromate.songs.commands.config
|
||||||
|
thumbnail = negromate.songs.commands.thumbnail
|
||||||
|
|
||||||
[bdist_wheel]
|
[bdist_wheel]
|
||||||
# This flag says to generate wheels that support both Python 2 and Python
|
# This flag says to generate wheels that support both Python 2 and Python
|
||||||
|
|
40
setup.py
40
setup.py
|
@ -1,40 +0,0 @@
|
||||||
import setuptools
|
|
||||||
from os import path
|
|
||||||
|
|
||||||
here = path.abspath(path.dirname(__file__))
|
|
||||||
|
|
||||||
with open(path.join(here, 'README.md'), encoding='utf-8') as f:
|
|
||||||
long_description = f.read()
|
|
||||||
|
|
||||||
with open(path.join(here, 'VERSION'), encoding='utf-8') as f:
|
|
||||||
version = f.read()
|
|
||||||
|
|
||||||
|
|
||||||
setuptools.setup(
|
|
||||||
name="negromate.songs",
|
|
||||||
version=version,
|
|
||||||
author="Ales (Shagi) Zabala Alava",
|
|
||||||
author_email="shagi@gisa-elkartea.org",
|
|
||||||
url="http://negromate.rocks",
|
|
||||||
description="NegroMate karaoke song database library",
|
|
||||||
long_description=long_description,
|
|
||||||
long_description_content_type='text/markdown',
|
|
||||||
packages=setuptools.find_namespace_packages(include=['negromate.*']),
|
|
||||||
classifiers=[
|
|
||||||
"Development Status :: 5 - Production/Stable",
|
|
||||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"Topic :: Games/Entertainment",
|
|
||||||
],
|
|
||||||
python_requires=">=3.4",
|
|
||||||
install_requires=[
|
|
||||||
"webvtt-py",
|
|
||||||
"asstosrt==0.1.6",
|
|
||||||
"srt==1.6.0",
|
|
||||||
],
|
|
||||||
entry_points={
|
|
||||||
'console_scripts': [
|
|
||||||
'negromate-songs=negromate.songs.command:main',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
Loading…
Reference in New Issue