#!/usr/bin/env python3 """Count down the seconds until a command needs to be run, announcing how much time is left periodically.""" # pylint: disable=superfluous-parens # superfluous-parens: Parentheses are good for portability and clarity import hashlib import os import random import re import shlex import subprocess import sys import time import typing sys.path.append("/usr/local/lib") # noqa: E402 import speak def get_not_music() -> typing.Set[str]: """Return a set of files that are "not music".""" result_set: typing.Set[str] = set() with subprocess.Popen("not-music", stdout=subprocess.PIPE) as subp: tuple_ = subp.communicate() full_output = tuple_[0].rstrip(b"\n") for line in full_output.split(b"\n"): result_set.add(str(line, encoding="UTF-8")) return result_set NOT_MUSIC = get_not_music() def make_used(var: typing.Any) -> None: """Convince pyflakes that var is used.""" assert True or var def usage(retval: int) -> None: """Output a usage message.""" sys.stderr.write("Usage: {}\n".format(sys.argv[0])) sys.stderr.write(" --period 60 Number of seconds per announcement period\n") sys.stderr.write(" --count 0 Number of seconds to wait before playing music\n") sys.stderr.write(" --specific *.mp3 Music files to play after count finishes\n") sys.stderr.write(" --random-music count Number of random music files to play\n") sys.stderr.write(" --nfiles count Number of random music files to play (minus --specific files)\n") sys.stderr.write(" --lock Lock screen after playing half the music files\n") sys.stderr.write(" --display-off Turn off screen after playing half the music files\n") sys.stderr.write(" --hibernate Hibernate the system when done playing\n") sys.stderr.write(" --poweroff power down the system when done playing\n") sys.exit(retval) def quotes(list_: typing.List[str]) -> str: """Quote a list for the shell.""" result = [] result.append("'") result.extend("' '".join(list_)) result.append("'") return "".join(result) def spawn(command: str | list[str]) -> None: """Run command as a shell command.""" # We intentionally ignore the exit code if isinstance(command, str): command_list = shlex.split(command) elif isinstance(command, list): command_list = command else: raise ValueError subprocess.run(command_list, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def period_description(num_periods: int, period: int) -> str: """Describe the period. This will usually be minutes.""" if num_periods == 1: ess = "" else: ess = "s" if period == 1: return "second" if period == 60: return "minute" if period == 60 * 60: return "hour" return "{} second period{}".format(period, ess) def is_hidden(filename: str) -> bool: """Return true iff this is a "hidden" file.""" return filename.startswith(".") def is_mp3(filename: str) -> bool: """Return true iff filename is an mp3 and isn't a "hidden file".""" return filename.endswith(".mp3") def get_dir() -> str: """Get a directory containing mp3 files.""" trial_directories = [ "~/sound/not-backed-up/Phone-Music", "~/not-backed-up/Phone-Music", "~/sound-local/Phone-Music", "~/sound-local/avconv-Music", "~/sound/Phone-Music", "~/sound/avconv-Music", "~/not-backed-up/Phone-Music", "/mymount/sound/Phone-Music", "/mymount/sound/avconv-Music", ] for trial_directory in trial_directories: assert not trial_directory.endswith(os.path.sep) for trial_directory in trial_directories: directory = os.path.expanduser(trial_directory) if os.path.isdir(directory): print("Getting music from {}".format(directory)) return directory raise ValueError("No sound files found") DIRECTORY = get_dir() def ripemd160_hasher(filename: str): """Return the ripemd160 hash for filename.""" hasher = hashlib.new("ripemd160") with open(filename, "rb") as file_: # We can just read the whole file, because we know the file is small. hasher.update(file_.read()) return hasher.hexdigest() class FileComparator: # pylint: disable=too-few-public-methods """Memorize a couple of attributes of a file, for subsequent equality comparison to other files.""" def __init__(self, original_filename: str) -> None: """Initialize.""" self.filename = original_filename del original_filename self.file_length = os.path.getsize(self.filename) # In practice, this will be only about 6 kilobytes assert self.file_length < 1024 * 1024 self.ripemd160_hash = ripemd160_hasher(self.filename) def is_same(self, other_filename: str) -> bool: """Compare another file to the original file.""" return os.path.getsize(other_filename) == self.file_length and ripemd160_hasher(other_filename) == self.ripemd160_hash def gen_mp3s() -> typing.Generator[str, None, None]: """Yield all the mp3's under .""" for root, dirs, files in os.walk("."): make_used(dirs) norm_root = os.path.normpath(root) for filename in files: # pathname = os.path.join(re.sub(r'^\./', '', root), filename) pathname = os.path.join(norm_root, filename) if is_hidden(pathname): continue if is_mp3(pathname): yield pathname def randomize(filenames: typing.List[str], num_files: int) -> typing.Generator[str, None, None]: """Return num_files elements from filenames.""" len_filenames = len(filenames) sys.stderr.write("Number of mp3's found: {}\n".format(len_filenames)) for index in range(num_files): make_used(index) randno = random.randint(0, len_filenames - 1) result_filename = filenames[randno] yield result_filename class Options(object): # pylint: disable=too-few-public-methods # too-few-public-methods: We're mostly a container """Parse, check and hold command line options.""" def __init__(self) -> None: """Initialize.""" self.period = 60 self.count = 0 self.command = "" self.specific_files: typing.List[str] = [] self.random_music = 0 self.nfiles = 0 self.lock = False self.display_off = False self.hibernate = False self.poweroff = False def parse_argv(self) -> None: """Parse up argv and store results in attributes.""" while sys.argv[1:]: if sys.argv[1] == "--period": self.period = max(int(sys.argv[2]), 1) del sys.argv[1] elif sys.argv[1] == "--count": self.count = int(sys.argv[2]) del sys.argv[1] elif sys.argv[1] in ("--specific", "--mplayer"): self.specific_files = sys.argv[2:] del sys.argv[2:] elif sys.argv[1] == "--random-music": self.random_music = int(sys.argv[2]) del sys.argv[2] elif sys.argv[1] == "--nfiles": self.nfiles = int(sys.argv[2]) del sys.argv[2] elif sys.argv[1] == "--lock": self.lock = True elif sys.argv[1] == "--display-off": self.display_off = True elif sys.argv[1] == "--hibernate": self.hibernate = True elif sys.argv[1] == "--poweroff": self.poweroff = True elif sys.argv[1] in ["-h", "--help"]: usage(0) else: message = "{}: Unrecognized option: {}\n" sys.stderr.write(message.format(sys.argv[0], sys.argv[1])) usage(1) del sys.argv[1] if self.command == "" and self.specific_files == "" and self.nfiles == 0 and self.random_music == 0: message = "{}: Warning: No action specified\n" sys.stderr.write(message.format(sys.argv[0])) all_regular_files = True for specific_file in self.specific_files: if not os.path.isfile(specific_file): sys.stderr.write("{}: {} does not exist or is not a regular file\n".format(sys.argv[0], specific_file)) all_regular_files = False if not all_regular_files: sys.stderr.write("{}: one or more values to --specific not a regular file\n".format(sys.argv[0])) sys.exit(1) if self.nfiles != 0: if self.random_music != 0: raise SystemExit("--nfiles and --random-music are mutually exclusive.\n") self.random_music = self.nfiles - len(self.specific_files) if self.random_music < 0: raise SystemExit(f"Too many --specific files: {len(self.specific_files)}\n") def speechize(filename: str) -> str: """Convert a filename to something a little more speech-friendly.""" result1 = filename.replace("/", ". ") result2 = re.sub(r"\.mp3$", ".", result1) result3 = re.sub("_", " ", result2) return result3 def specifics(*, lock, display_off, hibernate, poweroff, filenames: typing.List[str]) -> None: """Spawn one mpg123 for each filename, sequentially.""" filenames_list = list(filenames) len_filenames = len(filenames_list) for filenameno, filename in enumerate(filenames_list): if filenameno >= len_filenames / 2.0: if lock: print("Locking") spawn("cinnamon-screensaver-command --lock") lock = False if display_off: print("Turning off display") spawn("xset dpms force off") display_off = False speechy_filename = speechize(filename) sys.stderr.write("Playing {} of {}: {}\n".format(filenameno + 1, len_filenames, filename)) sys.stderr.flush() speak.speak("Starting {} of {}: {}".format(filenameno + 1, len_filenames, speechy_filename)) spawn(["mpg123", filename]) speak.speak("That was {}".format(speechy_filename)) if poweroff: spawn("/usr/bin/sudo /sbin/poweroff") elif hibernate: spawn("/usr/bin/sudo /usr/bin/systemctl hibernate") def chop(filename: str) -> str: """Chop off the music directory, to keep pathnames more listenable.""" prefix = DIRECTORY + "/" if filename.startswith(prefix): length = len(prefix) return filename[length:] else: return filename def normalize_and_chop(in_files: typing.List[str]) -> typing.List[str]: """Make files absolute, and then chop off path to music (if any).""" abs_files = [os.path.abspath(f) for f in in_files] out_files = [chop(f) for f in abs_files] return out_files def main() -> None: """Play music.""" options = Options() options.parse_argv() prior_quotient = int(options.count // options.period) + 1 time0 = time.time() while True: time1 = time.time() if time0 + options.count < time1: if options.command: spawn(options.command) specific_list = [] if options.specific_files: result = normalize_and_chop(options.specific_files) specific_list.extend(result) # The timing of this chdir is important os.chdir(DIRECTORY) if options.random_music: random_songs = list(gen_mp3s()) random_songs_set = set(random_songs) # If this assertion fails, then we should expect a file in the not-music script that isn't in the Phone-Music # directory. Pay particular attention to the .mp3 vs .ogg and .flac extensions. assert NOT_MUSIC & random_songs_set == NOT_MUSIC, str(NOT_MUSIC) random_songs_set -= NOT_MUSIC random_songs_list = list(random_songs_set) result = list(randomize(random_songs_list, options.random_music)) specific_list.extend(result) if specific_list: specifics( lock=options.lock, display_off=options.display_off, hibernate=options.hibernate, poweroff=options.poweroff, filenames=specific_list, ) sys.exit(0) difference = time1 - time0 current_quotient = int((options.count - difference) // options.period) + 1 if prior_quotient != current_quotient: prior_quotient = current_quotient speak.speak( "countdown {}, {}, remaining\n".format( current_quotient, period_description(current_quotient, options.period), ) ) count_minus_difference = round(options.count - difference) if count_minus_difference != 0: print(count_minus_difference) time.sleep(1) main()