sya

split youtube audio tracks, with an optional pyqt gui
git clone git://src.gearsix.net/sya
Log | Files | Refs | Atom | README

commit e19eb69125455f5dd5a1cb5ac1a081855781b086
parent d6eeb385e65ea2a8a552b6620bc5450fc7d12b27
Author: gearsix <gearsix@tuta.io>
Date:   Fri,  4 Nov 2022 22:02:31 +0000

sya-pyqt: massive refactor to tidyup and make it more manageable.

Not much changed architecturally, aside from a few renames and that `_init_combobox` and `_init_filepicker` are now their own functions . Naming was more standardised and PyQt signals are effectively used.

Also 'log()' was removed from *sya.py* (was unnecessary) and `shell=True` was added to all the `Popen` calls to avoid a console window popping up in the sya-pyqt binary. Also (finally) fixed the string decoding.

Diffstat:
Msya-pyqt.py | 440++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msya.py | 44++++++++++++++++++++++----------------------
2 files changed, 278 insertions(+), 206 deletions(-)

diff --git a/sya-pyqt.py b/sya-pyqt.py @@ -20,225 +20,297 @@ def resource_path(relative_path): base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) -def centerWidget(widget): + +def center_widget(widget): sg = qtwidg.QDesktopWidget().screenGeometry() wg = widget.geometry() return qtcore.QPoint( round(sg.width() / 2) - round(wg.width() / 2), round(sg.height() / 2) - round(wg.height() / 2)) -class LogStream(qtcore.QObject): - txt = qtcore.pyqtSignal(str) - - def write(self, txt): - self.txt.emit(str(txt)) -class SyaGuiMain(qtcore.QThread): +class SyaGuiThread(qtcore.QThread): def __init__(self, fn, args=None): super().__init__() self.fn = fn self.args = args def run(self): - if self.args != None: - self.fn(self.args) - else: + if self.args is None: self.fn() + else: + self.fn(self.args) + + +class SyaGuiLogStream(qtcore.QObject): + txt = qtcore.pyqtSignal(str) + + def write(self, txt): + self.txt.emit(str(txt)) + + +def sya_gui_combobox(parent, label, items, default_item, fn_update): + label = qtwidg.QLabel(label, parent) + + combobox = qtwidg.QComboBox(parent) + for i in items: + combobox.addItem(i) + if default_item in items: + combobox.setCurrentIndex(items.index(default_item)) + combobox.activated[str].connect(fn_update) + + layout = qtwidg.QHBoxLayout() + layout.addWidget(label) + layout.addWidget(combobox) + layout.setStretch(0, 2) + + return layout + + +def sya_gui_filepicker(parent, label, fn_select, fn_update, default_value='', icon=''): + label = qtwidg.QLabel(label, parent) + + lineEdit = qtwidg.QLineEdit(parent) + lineEdit.setText(default_value) + lineEdit.textChanged.connect(fn_update) + + btnIcon = qtgui.QIcon(resource_path('{}.png'.format(icon))) + btn = qtwidg.QPushButton(btnIcon, '', parent) + btn.clicked.connect(fn_select) + + layout = qtwidg.QHBoxLayout() + layout.addWidget(label) + layout.addWidget(lineEdit) + layout.addWidget(btn) + + return layout, lineEdit + class SyaGui(qtwidg.QMainWindow): - def __init__(self, fnSya, args): + def __init__(self, fn_sya, fn_sya_args): super().__init__() - self.args = args - self.fnSya = fnSya + self.fnSya = fn_sya + self.fnSyaArgs = fn_sya_args + + self.availableFormats = ['mp3', 'flv', 'wav', 'ogg', 'aac'] + self.availableQualities = ['0 (better)', '1', '2', '3', '4', '5', '6', '7', '8', '9 (worse)'] + + self._init_options_value() + self._init_options() + self._init_logger() + + self.optionsQuit.clicked.connect(self.quit) + self.optionsOk.clicked.connect(self.main) + self.loggerCancel.clicked.connect(self.cancel) + self.loggerDone.clicked.connect(self.done) + + sys.stdout = SyaGuiLogStream(txt=self.log) + self.running = 0 + + # Runtime Methods + def log(self, msg): + cursor = self.loggerTextbox.textCursor() + cursor.insertText(msg) + self.loggerTextbox.setTextCursor(cursor) + self.loggerTextbox.ensureCursorVisible() + + def cancel(self): + if self.running > 0: + self.main_t.terminate() + self.main_t.wait() + self.running -= 1 + if os.path.exists(self.fnSyaArgs.output): + shutil.rmtree(self.fnSyaArgs.output) + self.logger.hide() + self.loggerTextbox.clear() + + def quit(self): + if self.running > 0: + self.cancel() + del self.logger + del self.options + sys.exit() - self._edits = {} - options = qtwidg.QWidget() - options.setWindowTitle('sya') - options = self._init_options(options) - #options.setWindowIcon(pyqt_options.QIcon('')) - options.move(centerWidget(options)) - self._options = options - - logs = qtwidg.QWidget() - logs.resize(800, 400) - logs = self._init_logs(logs) - logs.move(centerWidget(logs)) - self._logs = logs - - sys.stdout = LogStream(txt=self.log) - self._options.show() - - def _init_options(self, options): - layout = qtwidg.QGridLayout() - # tracklist - self._tracklistLabel = 'Tracklist:' - layout.addLayout(self._init_filepicker(options, self._tracklistLabel, - self._filepicker_tracklist, self.args.tracklist, 'file'), 0, 0, 1, 3) - # formats - formats = ['mp3', 'flv', 'wav', 'ogg', 'aac'] - layout.addLayout(self._init_combobox(options, 'Format:', self._set_format, formats, - self.args.format), 1, 0) - # quality - qualities = ['0 (better)', '1', '2', '3', '4', '5', '6', '7', '8', '9 (worse)'] - layout.addLayout(self._init_combobox(options, 'Quality:', self._set_quality, qualities, - self.args.quality), 2, 0) - # keep - keep = qtwidg.QCheckBox('keep original', options) - if self.args.keep == True: - keep.setChecked(True) - keep.toggled.connect(self._keep_toggle, self.args.keep) - layout.addWidget(keep, 1, 2, 2, 1) - # output - self._outputLabel = 'Output:' - layout.addLayout(self._init_filepicker(options, self._outputLabel, self._filepicker_output, - self.args.output), 3, 0, 1, 3) - # quit - quit_btn = qtwidg.QPushButton('Quit') - quit_btn.clicked.connect(sys.exit) - layout.addWidget(quit_btn, 4, 1) - # ok - self._ok_btn = qtwidg.QPushButton('OK') - self._ok_btn.clicked.connect(self._ok) - layout.addWidget(self._ok_btn, 4, 2) - self._check_ok() - - options.setLayout(layout) - return options - - def _init_logs(self, logs): - layout = qtwidg.QGridLayout() - # textbox - logbox = qtwidg.QPlainTextEdit() - logbox.setReadOnly(True) - self._logbox = logbox - layout.addWidget(logbox, 1, 0, 1, 5) - # cancel - cancel_btn = qtwidg.QPushButton('Cancel') - cancel_btn.clicked.connect(self._cancel) - layout.addWidget(cancel_btn, 2, 0) - # warning - warning = qtwidg.QLabel('Be patient, this might take a while. Click "Done" when finished.') - layout.addWidget(warning, 2, 1, 1, 2) - # done - self._done_btn = qtwidg.QPushButton('Done') - self._done_btn.clicked.connect(sys.exit) - self._done_btn.setEnabled(False) - layout.addWidget(self._done_btn, 2, 4) - - logs.setLayout(layout) - return logs - - def _init_filepicker(self, widget, labelText, filepickerFn, default=None, icon='folder'): - layout = qtwidg.QHBoxLayout() - # label - label = qtwidg.QLabel(labelText, widget) - layout.addWidget(label) - # line edit - self._edits[labelText] = qtwidg.QLineEdit(widget) - if default != None: - self._edits[labelText].setText(default) - layout.addWidget(self._edits[labelText]) - # filepicker btn - button_logo = qtgui.QIcon(resource_path('{}.png'.format(icon))) - button = qtwidg.QPushButton(button_logo, '', widget) - button.clicked.connect(filepickerFn) - layout.addWidget(button) + def done(self): + self.loggerTextbox.clear() + self.logger.hide() + + def preMain(self): + self.optionsOk.setEnabled(False) + self.loggerDone.setEnabled(False) + + def postMain(self): + self.optionsOk.setEnabled(True) + self.loggerDone.setEnabled(True) + + def main(self): + self.fnSyaArgs.tracklist = self.optionsValue[self.tracklistLabel] + self.fnSyaArgs.format = self.optionsValue[self.formatLabel] + self.fnSyaArgs.quality = self.optionsValue[self.qualityLabel] + self.fnSyaArgs.keep = self.optionsValue[self.keepLabel] + self.fnSyaArgs.output = self.optionsValue[self.outputLabel] + + self.main_t = SyaGuiThread(self.fnSya, self.fnSyaArgs) + self.main_t.started.connect(self.preMain) + self.main_t.finished.connect(self.postMain) + + self.logger.setWindowTitle(self.fnSyaArgs.output) + self.logger.show() + self.main_t.start() + # optionsValue + def _init_options_value(self): + self.tracklistLabel = 'Tracklist:' + self.formatLabel = 'Format:' + self.qualityLabel = 'Quality:' + self.keepLabel = 'Keep unsplit audio file' + self.outputLabel = 'Output:' + self.optionsValue = { + self.tracklistLabel: self.fnSyaArgs.tracklist, + self.formatLabel: self.fnSyaArgs.format, + self.qualityLabel: self.fnSyaArgs.quality, + self.keepLabel: self.fnSyaArgs.keep, + self.outputLabel: self.fnSyaArgs.output + } + + # options + def _init_options(self): + self.options = qtwidg.QWidget() + self.optionsOk = qtwidg.QPushButton('OK') + self.optionsQuit = qtwidg.QPushButton('Quit') + + layout = qtwidg.QGridLayout() + layout.addLayout(self._init_options_tracklist(), 0, 0, 1, 3) + layout.addLayout(self._init_options_format(), 1, 0) + layout.addLayout(self._init_options_quality(), 2, 0) + layout.addLayout(self._init_options_output(), 3, 0, 1, 3) + layout.addWidget(self._init_options_keep(), 1, 2, 2, 1) + layout.addWidget(self.optionsQuit, 4, 1) + layout.addWidget(self.optionsOk, 4, 2) + + self.update_options_ok() + + self.options.setLayout(layout) + self.options.setWindowTitle('sya (split youtube audio)') + self.options.move(center_widget(self.options)) + self.options.show() + + def _init_options_tracklist(self): + label = self.tracklistLabel + layout, self.optionsTracklist = sya_gui_filepicker(self.options, label, self.select_tracklist, self.set_tracklist, self.optionsValue[label], 'file') return layout - def _init_combobox(self, widget, label, setFn, options, default): - layout = qtwidg.QHBoxLayout() - # label - label = qtwidg.QLabel(label, widget) - layout.addWidget(label) - # combobox - combo = qtwidg.QComboBox(widget) - for opt in options: - combo.addItem(opt) - if default in options: - combo.setCurrentIndex(options.index(default)) - combo.activated[str].connect(setFn) - layout.addWidget(combo) - - layout.setStretch(0, 2) + def _init_options_format(self): + label = self.formatLabel + self.optionsFormat = sya_gui_combobox(self.options, label, self.availableFormats, self.optionsValue[label], self.set_format) + return self.optionsFormat + + def _init_options_quality(self): + label = self.qualityLabel + self.optionsQuality = sya_gui_combobox(self.options, label, self.availableQualities, self.optionsValue[label], self.set_quality) + return self.optionsQuality + + def _init_options_keep(self): + label = self.keepLabel + self.optionsKeep = qtwidg.QCheckBox(label, self.options) + if self.optionsValue[label]: + self.optionsKeep.setChecked(True) + self.optionsKeep.toggled.connect(self.toggle_keep) + return self.optionsKeep + + def _init_options_output(self): + label = self.tracklistLabel + layout, self.optionsOutput = sya_gui_filepicker(self.options, label, self.select_output, self.set_output, self.optionsValue[label], 'folder') return layout - - def _filepicker_tracklist(self, signal): - file = qtwidg.QFileDialog.getOpenFileName(self._options, - 'Select a tracklist', os.path.expanduser('~'), "Text file (*.txt)", - None, qtwidg.QFileDialog.DontUseNativeDialog) - if len(file) > 0: - self.args.tracklist = file[0] - self._edits[self._tracklistLabel].setText(self.args.tracklist) - if len(self._edits[self._outputLabel].text()) == 0: - self.args.output = os.path.splitext(self.args.tracklist)[0] - self._edits[self._outputLabel].setText(self.args.output) - self._check_ok() - - def _filepicker_output(self, signal): - file = qtwidg.QFileDialog.getExistingDirectory(self._options, - 'Select directory', os.path.expanduser('~'), - qtwidg.QFileDialog.DontUseNativeDialog) - if len(file) > 0: - self.args.output = file - self._edits[self._outputLabel].setText(file) - self._check_ok() - - def _set_format(self, format): - self.args.format = format - def _set_quality(self, quality): - self.args.quality = quality[0] + # Options Callbacks + def select_tracklist(self): + file = qtwidg.QFileDialog.getOpenFileName(self.options, 'Select a tracklist', os.path.expanduser('~'), "Text file (*.txt)", None, qtwidg.QFileDialog.DontUseNativeDialog) + if len(file) > 0: + self.set_tracklist(file[0]) - def _keep_toggle(self): - self.args.keep = not self.args.keep + def set_tracklist(self, text): + self.optionsValue[self.tracklistLabel] = text + self.optionsTracklist.setText(text) + self.set_output(os.path.splitext(text)[0]) + self.update_options_ok() - def _check_ok(self): - if self.args.tracklist != None and self.args.output != None and \ - os.path.exists(self.args.tracklist) and len(self.args.output) > 0: - self._ok_btn.setEnabled(True) + def select_output(self): + file = qtwidg.QFileDialog.getExistingDirectory(self.options, 'Select directory', os.path.expanduser('~'), qtwidg.QFileDialog.DontUseNativeDialog) + if len(file) > 0: + self.set_output(file[0]) + + def set_output(self, text): + self.optionsValue[self.outputLabel] = text + self.optionsOutput.setText(text) + self.update_options_ok() + + def set_format(self, option): + if option not in self.availableFormats: + return + self.optionsValue[self.formatLabel] = option + + def set_quality(self, option): + if option not in self.availableQualities: + return + self.optionsValue[self.qualityLabel] = option + + def toggle_keep(self): + self.optionsValue[self.keepLabel] = not self.optionsValue[self.keepLabel] + + def update_options_ok(self): + tracklist = self.optionsValue[self.tracklistLabel] + output = self.optionsValue[self.outputLabel] + if os.path.exists(tracklist) and len(output) > 0: + self.optionsOk.setEnabled(True) else: - self._ok_btn.setEnabled(False) + self.optionsOk.setEnabled(False) - def _ok(self): - self._logs.show() - self.start_main() + # Logger Widget + def _init_logger(self): + layout = qtwidg.QGridLayout() + layout.addWidget(self._init_logger_textbox(), 1, 0, 1, 5) + layout.addWidget(self._init_logger_cancel(), 2, 0) + layout.addWidget(self._init_logger_warning(), 2, 1, 1, 2) + layout.addWidget(self._init_logger_done(), 2, 4) + + self.logger = qtwidg.QWidget() + self.logger.setLayout(layout) + self.logger.resize(800, 400) + self.logger.move(center_widget(self.logger)) - def log(self, msg): - cursor = self._logbox.textCursor() - cursor.insertText(msg) - self._logbox.setTextCursor(cursor) - self._logbox.ensureCursorVisible() + def _init_logger_textbox(self): + self.loggerTextbox = qtwidg.QPlainTextEdit() + self.loggerTextbox.setReadOnly(True) + return self.loggerTextbox - def start_main(self): - self.main_t = SyaGuiMain(self.fnSya, args=self.args) - self.check_t = SyaGuiMain(self._check_done) - self.main_t.start() - self.check_t.start() + def _init_logger_cancel(self): + self.loggerCancel = qtwidg.QPushButton('Cancel') + return self.loggerCancel + + @staticmethod + def _init_logger_warning(): + return qtwidg.QLabel('This might take a while. You can click "Done" when it\'s finished.') - def _check_done(self): - while self.main_t.isFinished() != True: - continue - self._ok_btn.setEnabled(True) - self._options.setEnabled(False) + def _init_logger_done(self): + self.loggerDone = qtwidg.QPushButton('Done') + self.loggerDone.setEnabled(False) + return self.loggerDone - def _cancel(self): - self.main_t.exit() - self.check_t.exit() - shutil.rmtree(self.args.output) - del(self._logs) +# Main if __name__ == '__main__': app = qtwidg.QApplication(sys.argv) - gui = SyaGui(sya.sya, sya.parse_args()) + args = sya.parse_args() + args.tracklist = '' + args.output = '' + args.youtubedl = resource_path('yt-dlp') + args.ffmpeg = resource_path('ffmpeg') if sys.platform == 'win32': - gui.args.youtubedl = resource_path('yt-dlp.exe') - gui.args.ffmpeg = resource_path('ffmpeg.exe') - else: - gui.args.youtubedl = resource_path('yt-dlp') - gui.args.ffmpeg = resource_path('ffmpeg') - - sys.exit(app.exec_()) + args.youtubedl += '.exe' + args.ffmpeg += '.exe' + gui = SyaGui(sya.sya, args) + sys.exit(app.exec_()) diff --git a/sya.py b/sya.py @@ -15,34 +15,29 @@ class TracklistItem: self.title = title # utilities -def log(msg): - print(msg) - def error_exit(msg): - log('exit failure "{}"'.format(msg)) + print('exit failure "{}"'.format(msg)) sys.exit() def check_bin(*binaries): for b in binaries: try: - subprocess.call([b], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) + subprocess.call([b], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, shell=True) except: error_exit('failed to execute {}'.format(b)) # functions def get_audio(youtubedl, url, outdir, format='mp3', quality='320K', keep=True, ffmpeg='ffmpeg'): - log('{} getting {}, {} ({})'.format(youtubedl, format, quality, url)) + print('Downloading {} ({}, {})...'.format(url, format, quality)) fname = '{}/{}'.format(outdir, os.path.basename(outdir), format) cmd = [youtubedl, url, '--newline', '--extract-audio', '--audio-format', format, - '--audio-quality', quality, '--prefer-ffmpeg', '-o', fname + '.%(ext)s'] + '--audio-quality', quality, '--prefer-ffmpeg', '--ffmpeg-location', ffmpeg, + '-o', fname + '.%(ext)s'] if keep == True: cmd.append('-k') - if ffmpeg != 'ffmpeg': - cmd.append('--ffmpeg-location') - cmd.append(ffmpeg) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - for line in iter(p.stdout.readline, b''): - log(line.decode('utf-8').rstrip('\n')) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) + for line in p.stdout.readlines(): + print(' {}'.format(line.decode('utf-8', errors='ignore').strip())) return '{}.{}'.format(fname, format) def load_tracklist(path): @@ -66,7 +61,7 @@ def parse_tracks(tracklist): timestamp = l.strip('[()]') sline.remove(l) if timestamp == None: - log('line {}, missing timestamp: "{}"'.format(lcount, line)) + print('line {}, missing timestamp: "{}"'.format(lcount, line)) title = ' '.join(sline).strip(' ') title = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", '', title) @@ -82,15 +77,18 @@ def missing_times(tracks): return missing def split_tracks(ffmpeg, audio_fpath, tracks, format='mp3', outpath='out'): - log('splitting tracks...') + print('Splitting...') cmd = ['ffmpeg', '-v', 'quiet', '-stats', '-i', audio_fpath, '-f', 'null', '-'] - ret = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + ret = subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True) # some nasty string manip. to extract length (printed to stderr) try: length = str(ret).split('\\r') - length = length[len(length)-1].split(' ')[1].split('=')[1][:-3] + if sys.platform == 'win32': + length = length[len(length)-2].split(' ')[1].split('=')[1][:-3] + else: + length = length[len(length)-1].split(' ')[1].split('=')[1][:-3] except: - log('Failed to find track length, {}'.format(length)) + print('Failed to find track length, {}'.format(length)) return for i, t in enumerate(tracks): @@ -98,13 +96,13 @@ def split_tracks(ffmpeg, audio_fpath, tracks, format='mp3', outpath='out'): end = length if i < len(tracks)-1: end = tracks[i+1].timestamp - log('\t{} ({} - {})'.format(outfile, t.timestamp, end)) + print(' {} ({} - {})'.format(outfile, t.timestamp, end)) cmd = ['ffmpeg', '-nostdin', '-y', '-loglevel', 'error', '-i', audio_fpath, '-ss', t.timestamp, '-to', end, '-acodec', 'copy', outfile] - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - for line in iter(p.stdout.readline, b''): - log(line.decode('utf-8').rstrip('\n')) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) + for line in p.stdout.readlines(): + print(' {}'.format(line.decode('utf-8', errors='ignore').strip())) return # runtime @@ -141,6 +139,8 @@ def sya(args): audio_fpath = get_audio(args.youtubedl, tracklist[0], args.output, args.format, args.quality, args.keep, args.ffmpeg) + if os.path.exists(audio_fpath) == False: + error_exit('download failed, aborting') tracks = parse_tracks(tracklist[1:])