sya

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

commit 8bf94aaff150db02052591ff3ef7794ef59b3e04
parent d6eeb385e65ea2a8a552b6620bc5450fc7d12b27
Author: gearsix <gearsix@tuta.io>
Date:   Fri, 25 Nov 2022 16:20:18 +0000

v1.0.0 release; Squashed commit of the following:

commit e132740e7229762c457312bc37b206fa46496e73
Author: gearsix <gearsix@tuta.io>
Date:   Thu Nov 24 12:24:41 2022 +0000

    minor adjustments in pyqt

    - options window is moved on show
    - added spacer in center of options
    - removed sizing from options

commit c189192efb5777efe5824f7146f62110ba8bae8e
Author: gearsix <gearsix@tuta.io>
Date:   Sat Nov 19 18:27:33 2022 +0000

    sya.py: added weight system to handle multiple timestamps on a line

    Now the correct timestamp should be picked if there are multiple in a line.
    - Only a timestamp at the start or end of a line will be chosen
    - If there is a timestamp at both the start AND the end, the weight is checked.
    Each timestamp found will add a weight to the left or right (depending where it's found).

    p.s. All subprocess calls use shell=False;

commit b0d0d528647c4cf4b74107924d70a6db97b268db
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 23:04:18 2022 +0000

    refactor: updated README.md

commit a8e5b5f63010020699b9a0f9c3e692ca4ea0945e
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 22:19:35 2022 +0000

    feature: added HELP in place of the (redundant) 'Quit' button

commit 2a4237212bf3c91dc727d86f2353010940dbf2f8
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 21:56:10 2022 +0000

    bugfix: realised shell=False is required on windows to avoid consoles popping up

commit 026f57a94251301c1f44afc99c5a23f4b93b0785
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 21:55:37 2022 +0000

    bugfix: _init_options_output was using the wrong label

commit a778fa5d41f46c3bfbed9a706cdca64b25c43a5b
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 21:40:03 2022 +0000

    bugfix: removed 'flv' format option

commit 9af98328f7729a502e9f57c304e87eb64d5863bb
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 15:01:07 2022 +0000

    bugfix: ffmpeg binary assigned to wrong arg in sya-pyqt __main__
    refactor: Removed centerWidget calls, annoyingly spawns on 2nd monitor
    refactor: added NoWrap to logger, output is easier to read

commit db33381e083fbba6dba7e237c55c37ffa1dd41f4
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 15:00:43 2022 +0000

    minor amendment in README

commit 5e49cf578c213d48ec2dfccc213eb4b466fef63d
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 18 14:58:41 2022 +0000

    bugfix: subprocess calls on posix work properly now.
    refactor: created read_tracklen(), from split_tracks.

    - shell=True had to be removed
    - Also added 'Success' print before clean exit

commit 09760175daa8d39d79a265230e9a17b1538b26cd
Author: gearsix <gearsix@tuta.io>
Date:   Thu Nov 17 00:13:58 2022 +0000

    Bunch of improvements to sya-pyqt

    - The options window is now a fixed size (400,169)
    - Removed the `del self.logger` and `del self.options` from quit (unnecessary)
    - Fixed the application exiting
    - Hooked `quit` into `options.closeEvent`
    - Removed the `shutil.rmtree(self.fnSyaArgs.output)`, seemed too dangerous.
    - Removed the stretch from `sya_gui_combobox` return

commit a957bb0bdea14e1aa3be4a5c8fd57629a82106a0
Author: gearsix <gearsix@tuta.io>
Date:   Sat Nov 12 20:13:04 2022 +0000

    docs: migrated a lot of doc files to *README.md*.

commit 8119c02c7aa3702da3833381dce1ac2037402ce4
Author: gearsix <gearsix@tuta.io>
Date:   Mon Nov 7 22:51:09 2022 +0000

    minor fixes & adjustments to argument defaults

    - default yt-dlp & ffmpeg argument have '.exe' on default for windows (if not provided)
    - sya-pyqt accepts yt-dlp & ffmpeg args (without overriding for bundled)
    - bugfix to logger text cursor, now it *always* appends (even if you click to move it)
    - check_options_ok called on *any* options change

commit 0ccde1a9477b53b04d33c07b1f0f481e8d77706a
Author: gearsix <gearsix@tuta.io>
Date:   Mon Nov 7 15:25:27 2022 +0000

    bugfix: optionsOk is enabled in done, not postMain

commit 69e98b910b09eef590df3ec1470725607418395e
Author: gearsix <gearsix@tuta.io>
Date:   Mon Nov 7 15:00:59 2022 +0000

    fix: sya-smol.png was still being used as the logo icon

commit 8fbaf50552dc3144f50fb20c919921677f7efa9e
Author: gearsix <gearsix@tuta.io>
Date:   Mon Nov 7 13:29:20 2022 +0000

    updated docs

commit 28665441c9cd73f9ee0a91491a2be77d1646bb7f
Author: gearsix <gearsix@tuta.io>
Date:   Mon Nov 7 13:05:29 2022 +0000

    added sya.png logo; tracklist & output reset on 'done'

commit e19eb69125455f5dd5a1cb5ac1a081855781b086
Author: gearsix <gearsix@tuta.io>
Date:   Fri Nov 4 22:02:31 2022 +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:
ABUGS.md | 29+++++++++++++++++++++++++++++
DBUGS.txt | 23-----------------------
AHELP.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DREADME.txt | 126-------------------------------------------------------------------------------
ATODO.txt | 3+++
Drequirements.txt | 41-----------------------------------------
Ascreenshot.PNG | 0
Msya-pyqt.py | 477++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Asya.png | 0
Msya.py | 110+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
11 files changed, 607 insertions(+), 417 deletions(-)

diff --git a/BUGS.md b/BUGS.md @@ -0,0 +1,29 @@ +# BUGS + +## Legend + +[ ] = Not started +[x] = Won't do +[~] = Doing +[*] = Done +- ... = Note about the above item + +## List + +--- + +**2021-07-31** [ ] don't apply file numbering when song names include file number already + + #regex-improvement + +--- + +**2021-06-22** [*] handle multiple timestamp regex matches + When there are multiple matches for the timestamp regex in a line, the last match is used as the timestamp. + + Discovered that the following line causes an issue because 2019 is counted as a timestamp + `58:18 C4C - Melted w_ Hazy Year (Chillhop Winter Essentials 2019).` + + #regex-improvement + +--- diff --git a/BUGS.txt b/BUGS.txt @@ -1,23 +0,0 @@ -# BUGS - -## Legend - -[ ] = Not started -[x] = Won't do -[~] = Doing -[*] = Done -- ... = Note about the above item - -## List - -**2021-07-31** [ ] don't apply file numbering when song names include file number already - - #regex-improvement - -**2021-06-22** [*] handle multiple timestamp regex matches - When there are multiple matches for the timestamp regex in a line, the last match is used as the timestamp. - - Discovered that the following line causes an issue because 2019 is counted as a timestamp - `58:18 C4C - Melted w_ Hazy Year (Chillhop Winter Essentials 2019).` - - #regex-improvemnet diff --git a/HELP.md b/HELP.md @@ -0,0 +1,65 @@ + +# Help + +**sya - split youtube audio**, downloads, converts & splits audio from youtube videos into multiple audio tracks. + +## Overview + +To work sya requires some manual work: **tracklist** information. For more details on this, see **Tracklists** below.<br/> +The rest of the options can be configured but are provided with defaults. + +Here's an overview of the options: + +- **Tracklist** - the text file containing tracklist information +- **Format** - set the format to convert the audio to +- **Quality** - set the audio quality to download in, for reference 5 is equal to *128k* +- **Keep unsplit file** - keep the downloaded audio file (before it gets split up) +- **Output** - the directory to download the audio track to and split it into multiple tracks + +The resulting files can be found at the *Output:* filepath on your system. + +If you've found a bug or want to suggest improvements, email: `gearsix@tuta.io` + +## Tracklists + +A tracklist is just a text file some where on your system. +It should contains: + +- A *youtube URL* to download the audio from, this should be on the first line. +- Timestamps and titles for each track to split, there should be one timestamp and one title per-track (this can usually be found in the youtube video description or a top comment). + +**Example** + +Below you can see the contents of an example playlist.<br/> +Try saving it to a text file on your computer and as a test if you like. + + https://www.youtube.com/watch?v=LbjcaMAhJRQ + Sneaky Snitch (0:00) + Fluffing a Duck (2:16) + Cipher (3:24) + Scheming Weasel (7:15) + Carefree (8:44) + Thatched Villagers (12:09) + Monkeys Spinning Monkeys (16:15) + Wallpaper (18:20) + Pixel Peeker Polka (21:59) + Killing Time (25:21) + Hitman (28:46) + The Cannery (32:07) + Cut and Run (35:09) + Life of Riley (38:44) + Quirky Dog (42:39) + The Complex (45:08) + Hyperfun (49:35) + Black Vortex (53:29) + Rock on Chicago (56:19) + Volatile Reaction (57:58) + On the Ground (1:00:44) + Wagon Wheel (electronic) (1:03:23) + Call to Adventure (1:08:26) + Hustle (1:12:33) + Cupids Revenge (1:14:34) + Dirt Rhodes (1:16:20) + Rhinoceros (1:18:20) + Who Likes to Party (1:21:43) + Spazzmatica Polka (1:26:01) diff --git a/README.md b/README.md @@ -0,0 +1,150 @@ + +# sya + +**sya - split youtube audio**, download, convert & split audio from youtube videos into multiple audio tracks using 'yt-dlp' and 'ffmpeg'. + +--- + +## sya.py + +This is the base Python script, which contains all the functionality: + + sya.py [OPTIONS] TRACKLIST + +### OPTIONS + +``` + -h, --help + show this help message and exit + + -o [PATH], --output [PATH] + specify the directory to write output files to (default: ./out) + + -f [FORMAT], --format [FORMAT] + specify the --audio-format argument to pass to yt-dlp (default: mp3) + + -q [QUALITY], --quality [QUALITY] + specify the --audio-quality argument to pass to yt-dlp (default: 320K) + + --yt-dlp [PATH] + path of the "yt-dlp" binary to use + + --ffmpeg [PATH] + path of the "ffmpeg" binary to use + + -k, --keep + keep any files removed during processing (full video/audio file) +``` + +### TRACKLIST + +**TRACKLIST** should be the filepath of a UTF-8 text file that has the URL of the video to download to audio track of on line 1. +Every line after this should be the track name and timestamp of each track, note that the timestamp is *where the track starts*. + +Regex is used to parse the tracks (timestamp and name), it tries to be fairly accomodating but isn't perfect - so here are a few rules: + +- Timestamps can be before or after the track name. +- Timestamps can be wrapped in `[]` or `()`. +- Timestamps can be `MM:SS` or `HH:MM:SS`. +- Timestamps can be split using `:` or `.`. + +<details> + <summary>Example Tracklist</summary> + <pre> + https://www.youtube.com/watch?v=LbjcaMAhJRQ + Sneaky Snitch (0:00) + Fluffing a Duck (2:16) + Cipher (3:24) + Scheming Weasel (7:15) + Carefree (8:44) + Thatched Villagers (12:09) + Monkeys Spinning Monkeys (16:15) + Wallpaper (18:20) + Pixel Peeker Polka (21:59) + Killing Time (25:21) + Hitman (28:46) + The Cannery (32:07) + Cut and Run (35:09) + Life of Riley (38:44) + Quirky Dog (42:39) + The Complex (45:08) + Hyperfun (49:35) + Black Vortex (53:29) + Rock on Chicago (56:19) + Volatile Reaction (57:58) + On the Ground (1:00:44) + Wagon Wheel (electronic) (1:03:23) + Call to Adventure (1:08:26) + Hustle (1:12:33) + Cupids Revenge (1:14:34) + Dirt Rhodes (1:16:20) + Rhinoceros (1:18:20) + Who Likes to Party (1:21:43) + Spazzmatica Polka (1:26:01) + </pre> +</details> + +### Install + +It's a Python script, so there are many ways to install it. + +Here's one that should work on *any* system: + + python3 ./setup.py install --user --record install.txt + +To **uninstall**, just remove all files recorded to *./install.txt*. + +--- + +## sya-pyqt + +Some people don't like the cli and I wanted to play with PyQt, so sya-pyqt wraps a nice GUI around the *sya.py* runtime. + +![screenshot](screenshot.png "sya-pyqt on Windows) + +### Development + +Working on or building requires *PyQt5*, this can be installed with `pip install PyQt5`. + +To run *sya-pyqt*, you don't need to build a binary everytime, you can just run `python ./sya-pyqt.py`. + +### Building + +[pyinstaller](https://pyinstaller.org) is used to build a portable binary for sya-pyqt for easy distribution. + +**POSIX (Linux, MacOS, BSD)** + + pyinstaller ./sya-pyqt.py -F --windowed --add-data ".\HELP.md;." --add-data "./folder.png:." --add-data "./file.png:." --add-data "./sya.png:." --add-data "$FFMPEG:." --add-data "$YT-DLP:." + +**Windows** + + pyinstaller ./sya-pyqt.py -F --windowed --add-data ".\HELP.md;." --add-data ".\folder.png;." --add-data ".\file.png;." --add-data ".\sya.png;." --add-data "$FFMPEG;." --add-data "$YT-DLP;." + +- Make sure you have a *yt-dlp* binary available, the filepath of this is referred to as *$YT-DLP*. +- Make sure you have a *ffmpeg* binary available, the filepath of this is referred to as *$FFMPEG*. +- In some cases, I've found the path of PyQt5 has had to be explicitly given to *pyinstaller*: +`--path <site-packages filepath>\PyQt5` + +Optionally, you can add an icon to the binary, I'd recommend installing [Pillow](https://python-pillow.org/) so you don't need to manually convert `sya.png` to an icon file. Just add `--icon "sya.png"` to the build command. + + +--- + +## Thanks + +These two tools do all the heavy lifting: +- youtube-dl (https://ytdl-org.github.io/youtube-dl/) +- ffmpeg (https://ffmpeg.org) + +And the cool folder & file icons used in 'sya-pyqt' are from the Palemoon MicroMoon theme: +https://repo.palemoon.org/Lootyhoof/micromoon + + +## Disclaimer + +It should go without saying, don't use this for pirating music. Get nice high-quality, properly tagged audio tracks from official services (shoutout to Bandcamp). +The tool was originally written to download radio mixes and ambient soundtracks for a DnD group. + +## Authors + +- gearsix diff --git a/README.txt b/README.txt @@ -1,126 +0,0 @@ -NAME - sya - split youtube audio - -SYNOPSIS - sya.py [OPTIONS] TRACKLIST - -DESCRIPTION - sya downloads, converts and splits youtube videos into multiple audio - tracks using `youtube-dl` and `ffmpeg`. - -OPTIONS - -h --help displays help message - -k, --keep - youtube-dl option, keep the full track on disk after post-processing, - the video is erased by default - -o, --output [PATH] - specify the directory to write output files to (defaults to a directory - named after the tracklist filename) - -f, --format [EXT] - specify the --audio-format argument to pass to youtube-dl (default: mp3) - -q, --quality [QUALITY] - specify the --audio-quality argument to pass (default: 320K) - --youtube-dl [PATH] - path of the youtube-dl binary to use - --ffmpeg [PATH] - path of the ffmpeg binary to use - -TRACKLIST - TRACKLIST files should be text file that has the URL/v=code of the youtube video to - download on the first line and the starting timestamp of each section to split, followed - by the title of that section section for every line after. - - Of course, you don't have to put the timestamp first, sya will try and accomocodate - whatever syntax is used, just beware that this might cause problems. - - Here's an example: - - https://www.youtube.com/watch?v=LbjcaMAhJRQ - Sneaky Snitch (0:00) - Fluffing a Duck (2:16) - Cipher (3:24) - Scheming Weasel (7:15) - Carefree (8:44) - Thatched Villagers (12:09) - Monkeys Spinning Monkeys (16:15) - Wallpaper (18:20) - Pixel Peeker Polka (21:59) - Killing Time (25:21) - Hitman (28:46) - The Cannery (32:07) - Cut and Run (35:09) - Life of Riley (38:44) - Quirky Dog (42:39) - The Complex (45:08) - Hyperfun (49:35) - Black Vortex (53:29) - Rock on Chicago (56:19) - Volatile Reaction (57:58) - On the Ground (1:00:44) - Wagon Wheel (electronic) (1:03:23) - Call to Adventure (1:08:26) - Hustle (1:12:33) - Cupids Revenge (1:14:34) - Dirt Rhodes (1:16:20) - Rhinoceros (1:18:20) - Who Likes to Party (1:21:43) - Spazzmatica Polka (1:26:01) - - -BUILDING - It's more convenient to build sya-pyqt into a single binary (bundled - with assets & libraries). - To do this 'pyinstaller' seems most convenient (installed via "pip - install pyinstaller"): - - pyinstaller sya-pyqt.py -F --windowed --add-data "folder.png:." --add-data "file.png:." - - On Windows, you'll need to use the following: - - pyinstaller sya-pyqt.py -F --windowed --add-data "folder.png;." --add-data "file.png;." - - This process also works to create binaries for the terminal version - of the tool (sya.py). When building that version, the "--windowed" - and "--add-data "folder.png:."" arguments don't need to be included. - -INSTALL - There are a lot of ways to install this tool. - - For most cases, you can just download and run the distribution binaries. - - FROM SOURCE - This method should work on all platforms to install it for the current - user. - Note that there are a lot of ways to install a python package from - source, this is just one. - - python3 ./setup.py install --user --record install.txt - - The "--user" argument will only install sya for the current user. Remove - it to install the tool to the system, although note that this will require - admin permissions. - - This will create a 'install.txt' file in the current directory. This file - contains all the files installed to your system. To uninstall using just - remove all of these files. - -THANKS - These two tools do all the heavy lifting: - - youtube-dl (https://ytdl-org.github.io/youtube-dl/) - - ffmpeg (https://ffmpeg.org) - - And the cool icons used are from the MicroMoon theme for Palemoon: - https://repo.palemoon.org/Lootyhoof/micromoon - -DISCLAIMER - It should go without saying, don't use this for pirating music. - If you do, you're a dick and you're working against whatever band/label you - love enough to download their product. - - Service like Bandcamp (https://bandcamp.com) are great and allow you download - audio files of the albums you've bought, properly tagged and available for - re-download whenever you need. - -AUTHORS - - gearsix (gearsix@tuta.io) - diff --git a/TODO.txt b/TODO.txt @@ -0,0 +1,2 @@ +- QSpacerItem should be dynamic, not static size +- On exit, SyaGuiLogStream gets delted (runtime error?) +\ No newline at end of file diff --git a/requirements.txt b/requirements.txt @@ -1,41 +0,0 @@ -altgraph==0.17.3 -Brotli==1.0.9 -build==0.9.0 -certifi==2022.9.24 -dbus-python==1.2.18 -distlib==0.3.6 -filelock==3.8.0 -meson==0.60.3 -MouseInfo==0.1.3 -mugshot==0.4.3 -mutagen==1.46.0 -packaging==21.3 -pep517==0.13.0 -pexpect==4.8.0 -platformdirs==2.5.2 -psutil==5.9.3 -ptyprocess==0.7.0 -PyAutoGUI==0.9.53 -pycairo==1.20.1 -pycryptodomex==3.15.0 -PyGetWindow==0.0.9 -PyGObject==3.42.2 -pyinstaller==5.6.2 -pyinstaller-hooks-contrib==2022.11 -PyMsgBox==1.0.9 -pyparsing==3.0.9 -pyperclip==1.8.2 -PyQt5==5.15.7 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.11.0 -PyRect==0.2.0 -PyScreeze==0.1.28 -python3-xlib==0.15 -pytweening==1.0.4 -tomli==2.0.1 -trash-cli==0.22.8.27 -ufw==0.36.1 -virtualenv==20.16.6 -virtualenv-clone==0.5.7 -websockets==10.4 -yt-dlp==2022.10.4 diff --git a/screenshot.PNG b/screenshot.PNG Binary files differ. diff --git a/sya-pyqt.py b/sya-pyqt.py @@ -20,225 +20,330 @@ 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) + + 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', '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_help() + self._init_logger() - 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): + self.options.closeEvent = self.quit + self.optionsHelp.clicked.connect(self.show_help) + self.optionsOk.clicked.connect(self.main) + self.help.closeEvent = self.hide_help + 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): + self.loggerTextbox.moveCursor(qtgui.QTextCursor.End) + self.loggerTextbox.textCursor().insertText(msg) + self.loggerTextbox.ensureCursorVisible() + + def cancel(self): + if self.running > 0: + self.main_t.terminate() + self.main_t.wait() + self.running -= 1 + self.logger.hide() + self.loggerTextbox.clear() + + def quit(self, event): + sys.stdout = sys.__stdout__ + if self.running > 0: + self.cancel() + self.options.close() + self.logger.close() + self.close() + + def done(self): + self.set_tracklist('') + self.set_output('') + self.optionsOk.setEnabled(True) + self.logger.hide() + self.loggerTextbox.clear() + + def show_help(self): + x = self.options.x() - self.options.width() - 50 + y = self.options.y() - self.options.height() + self.help.move(x, y) + self.help.show() + self.optionsHelp.setEnabled(False) + + def hide_help(self, signal): + self.help.hide() + self.optionsHelp.setEnabled(True) + + def preMain(self): + self.optionsOk.setEnabled(False) + self.loggerDone.setEnabled(False) + + def postMain(self): + 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.running += 1 + self.main_t.start() + + # optionsValue + def _init_options_value(self): + self.tracklistLabel = 'Tracklist:' + self.formatLabel = 'Format:' + self.qualityLabel = 'Quality:' + self.keepLabel = 'Keep unsplit 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.optionsHelp = qtwidg.QPushButton('Help') + 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) + 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.addItem(qtwidg.QSpacerItem(int(self.options.width()/4), 0, qtwidg.QSizePolicy.Expanding, qtwidg.QSizePolicy.Expanding)) + layout.addWidget(self._init_options_keep(), 1, 2, 2, 1) + layout.addLayout(self._init_options_output(), 3, 0, 1, 3) + layout.addWidget(self.optionsHelp, 4, 0) + layout.addWidget(self.optionsOk, 4, 2) + + self.options.setLayout(layout) + self.options.setWindowTitle('sya (split youtube audio)') + self.options.setWindowIcon(qtgui.QIcon(resource_path('sya.png'))) - return layout + self.update_options_ok() + self.options.show() - 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_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 _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 _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 _set_quality(self, quality): - self.args.quality = quality[0] + 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 _keep_toggle(self): - self.args.keep = not self.args.keep + def _init_options_output(self): + label = self.outputLabel + layout, self.optionsOutput = sya_gui_filepicker(self.options, label, self.select_output, self.set_output, self.optionsValue[label], 'folder') + return layout - 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 _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 + + # Options Callbacks + def select_tracklist(self): + dialog = qtwidg.QFileDialog() + dialog.setWindowIcon(qtgui.QIcon(resource_path('sya.png'))) + file = dialog.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 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 select_output(self): + dialog = qtwidg.QFileDialog() + dialog.setWindowIcon(qtgui.QIcon(resource_path('sya.png'))) + file = dialog.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 + self.update_options_ok() + + def set_quality(self, option): + if option not in self.availableQualities: + return + self.optionsValue[self.qualityLabel] = option + self.update_options_ok() + + def toggle_keep(self): + self.optionsValue[self.keepLabel] = not self.optionsValue[self.keepLabel] + self.update_options_ok() + + 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) + + # Help Widget + def _init_help(self): + self.help = qtwidg.QTextEdit() + with open(resource_path("HELP.md")) as f: + self.help.setMarkdown(f.read()) + self.help.resize(500, 500) + self.help.setReadOnly(True) + return + + # 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) - def _ok(self): - self._logs.show() - self.start_main() + self.logger = qtwidg.QWidget() + self.logger.setLayout(layout) + self.logger.setWindowIcon(qtgui.QIcon(resource_path('sya.png'))) + self.logger.resize(800, 400) - def log(self, msg): - cursor = self._logbox.textCursor() - cursor.insertText(msg) - self._logbox.setTextCursor(cursor) - self._logbox.ensureCursorVisible() - - 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_textbox(self): + self.loggerTextbox = qtwidg.QPlainTextEdit() + self.loggerTextbox.setReadOnly(True) + self.loggerTextbox.setLineWrapMode(qtwidg.QPlainTextEdit.NoWrap) + return self.loggerTextbox - def _check_done(self): - while self.main_t.isFinished() != True: - continue - self._ok_btn.setEnabled(True) - self._options.setEnabled(False) + def _init_logger_cancel(self): + self.loggerCancel = qtwidg.QPushButton('Cancel') + return self.loggerCancel - def _cancel(self): - self.main_t.exit() - self.check_t.exit() - shutil.rmtree(self.args.output) - del(self._logs) + @staticmethod + def _init_logger_warning(): + return qtwidg.QLabel('This might take a while. You can click "Done" when it\'s finished.') + def _init_logger_done(self): + self.loggerDone = qtwidg.QPushButton('Done') + self.loggerDone.setEnabled(False) + return self.loggerDone + + +# Main if __name__ == '__main__': app = qtwidg.QApplication(sys.argv) - gui = SyaGui(sya.sya, sya.parse_args()) - - 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 = sya.parse_args() + if args.tracklist is None: + args.tracklist = '' + if args.output is None: + args.output = '' + if args.youtubedl is None: + args.youtubedl = resource_path('yt-dlp') if sys.platform != 'win32' else resource_path('yt-dlp.exe') + if args.ffmpeg is None: + args.ffmpeg = resource_path('ffmpeg') if sys.platform != 'win32' else resource_path('ffmpeg.exe') + gui = SyaGui(sya.sya, args) + + sys.exit(app.exec_()) diff --git a/sya.png b/sya.png Binary files differ. diff --git a/sya.py b/sya.py @@ -15,34 +15,30 @@ 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=False) 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'] + cmd = [youtubedl, '--newline', '--extract-audio', '--audio-format', format, + '--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')) + cmd.append(url) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=False) + for line in p.stdout.readlines(): + print(' {}'.format(line.decode('utf-8', errors='ignore').strip())) return '{}.{}'.format(fname, format) def load_tracklist(path): @@ -57,16 +53,25 @@ def load_tracklist(path): def parse_tracks(tracklist): tracks = [] + weightR = 0 # num. timestamps on right-side + weightL = 0 # num. timestamps on left-side for lcount, line in enumerate(tracklist): sline = line.split(' ') timestamp = None - for l in sline: - if Timestamp.match(l): - timestamp = l.strip('[()]') + for i, l in enumerate(sline): + if i != 0 and i != len(sline)-1: + continue + elif Timestamp.match(l): + if timestamp == None or weightR > weightL: + timestamp = l.strip('[()]') + if i == 0: + weightL += 1 + else: + weightR += 1 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) @@ -81,30 +86,36 @@ def missing_times(tracks): missing.append(i) return missing -def split_tracks(ffmpeg, audio_fpath, tracks, format='mp3', outpath='out'): - log('splitting tracks...') - cmd = ['ffmpeg', '-v', 'quiet', '-stats', '-i', audio_fpath, '-f', 'null', '-'] - ret = subprocess.check_output(cmd, stderr=subprocess.STDOUT) - # some nasty string manip. to extract length (printed to stderr) +def read_tracklen(ffmpeg, track_fpath): + cmd = [ffmpeg, '-v', 'quiet', '-stats', '-i', track_fpath, '-f', 'null', '-'] + length = '00:00' try: + ret = subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=False) length = str(ret).split('\\r') - length = length[len(length)-1].split(' ')[1].split('=')[1][:-3] + # some nasty string manip. to extract length (printed to stderr) + 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] + print('Track length: {}'.format(length)) except: - log('Failed to find track length, {}'.format(length)) - return - + error_exit('Failed to find track length, aborting.') + return length + +def split_tracks(ffmpeg, audio_fpath, audio_len, tracks, format='mp3', outpath='out'): + print('Splitting...') for i, t in enumerate(tracks): outfile = '{}/{} - {}.{}'.format(outpath, str(i+1).zfill(2), t.title.strip(' - '), format) - end = length + end = audio_len 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=False) + for line in p.stdout.readlines(): + print(' {}'.format(line.decode('utf-8', errors='ignore').strip())) return # runtime @@ -115,21 +126,32 @@ def parse_args(): parser.add_argument('tracklist', metavar='TRACKLIST', nargs='?', help='tracklist to split audio by') # options - parser.add_argument('-o', '--output', metavar='PATH', type=str, nargs='?', dest='output', + parser.add_argument('-o', '--output', + metavar='PATH', type=str, nargs='?', dest='output', help='specify the directory to write output files to (default: ./out)') - parser.add_argument('-f', '--format', type=str, nargs='?', default='mp3', dest='format', + parser.add_argument('-f', '--format', + type=str, nargs='?', default='mp3', dest='format', help='specify the --audio-format argument to pass to yt-dlp (default: mp3)') - parser.add_argument('-q', '--quality', type=str, nargs='?', default='320K', dest='quality', + parser.add_argument('-q', '--quality', + type=str, nargs='?', default='320K', dest='quality', help='specify the --audio-quality argument to pass to yt-dlp (default: 320K)') - parser.add_argument('--yt-dlp', metavar='PATH', type=str, nargs='?', default='yt-dlp', dest='youtubedl', + parser.add_argument('--yt-dlp', + metavar='PATH', type=str, nargs='?', dest='youtubedl', help='path of the "yt-dlp" binary to use') - parser.add_argument('--ffmpeg', metavar='PATH', type=str, nargs='?', default='ffmpeg', dest='ffmpeg', + parser.add_argument('--ffmpeg', + metavar='PATH', type=str, nargs='?', dest='ffmpeg', help='path of the "ffmpeg" binary to use') - parser.add_argument('-k', '--keep', action='store_true', default=False, dest='keep', + parser.add_argument('-k', '--keep', + action='store_true', default=False, dest='keep', help='keep any files removed during processing (full video/audio file)') return parser.parse_args() def sya(args): + if args.youtubedl == None: + args.youtubedl = 'yt-dlp.exe' if sys.platform == 'win32' else 'yt-dlp' + if args.ffmpeg == None: + args.ffmpeg = 'ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg' + if check_bin(args.youtubedl, args.ffmpeg) == False: error_exit('required binaries are missing') if args.tracklist == None or os.path.exists(args.tracklist) == False: @@ -141,18 +163,24 @@ def sya(args): audio_fpath = get_audio(args.youtubedl, tracklist[0], args.output, args.format, args.quality, args.keep, args.ffmpeg) - - tracks = parse_tracks(tracklist[1:]) + if os.path.exists(audio_fpath) == False: + error_exit('download failed, aborting') + + tracks = parse_tracks(tracklist[1:]) + missing = missing_times(tracks) if len(missing) > 0: error_exit('some tracks are missing timestamps') + length = read_tracklen(args.ffmpeg, audio_fpath) os.makedirs(args.output, exist_ok=True) - split_tracks(args.ffmpeg, audio_fpath, tracks, args.format, args.output) + split_tracks(args.ffmpeg, audio_fpath, length, tracks, args.format, args.output) if args.keep is False: os.remove(audio_fpath) + print('Success') + if __name__ == '__main__': sya(parse_args())