├── .github ├── example.png ├── phone_playlists.png └── playlists_git.png ├── .gitignore ├── LICENSE ├── P_README.md ├── README.md ├── functions.sh ├── mypy.ini ├── plainplay └── resolve_cmd_plainplay /.github/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purarue/plaintext-playlist/f0639499a0abee24b388fbb322a80499f38a532e/.github/example.png -------------------------------------------------------------------------------- /.github/phone_playlists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purarue/plaintext-playlist/f0639499a0abee24b388fbb322a80499f38a532e/.github/phone_playlists.png -------------------------------------------------------------------------------- /.github/playlists_git.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purarue/plaintext-playlist/f0639499a0abee24b388fbb322a80499f38a532e/.github/playlists_git.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags* 2 | *.pdf 3 | 4 | # Created by https://www.gitignore.io/api/python 5 | # Edit at https://www.gitignore.io/?templates=python 6 | 7 | ### Python ### 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # pipenv 77 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 78 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 79 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 80 | # install all needed dependencies. 81 | #Pipfile.lock 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # Mr Developer 97 | .mr.developer.cfg 98 | .project 99 | .pydevproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | .dmypy.json 107 | dmypy.json 108 | 109 | # Pyre type checker 110 | .pyre/ 111 | 112 | # End of https://www.gitignore.io/api/python 113 | 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 purarue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /P_README.md: -------------------------------------------------------------------------------- 1 | # plaintext-playlist 2 | 3 | ![Example Image](./.github/example.png) 4 | 5 | ``` 6 | >>>PMARK 7 | perl -E 'print "`"x3, "\n"' 8 | ./plainplay -h 9 | perl -E 'print "`"x3, "\n"' 10 | ``` 11 | 12 | ## Rationale 13 | 14 | I wanted a minimal, scriptable-friendly playlist for my local music, without having to rely on a third party playlist manager/GUI interface. 15 | 16 | _This stores playlists as text files, one per playlist, where each line is the (relative) path to a song in the playlist._ 17 | 18 | This includes a [`fzf`](https://github.com/junegunn/fzf) backed interactive mode. If you don't provide a playlist, this drops into interactive mode, letting you fuzzy match against playlist names, select multiple songs to add/remove, or select playlists to play/shuffle from. 19 | 20 | This only stores the relative filepath to your base music directory in each file, so you could move your music directory somewhere else and update the environment variable, and everything works, even across computers. However, filenames tend to change, and sometimes you might change the name of an artists' folder, or the name of an album to include metadata. So, `plainplay` has commands to help fix that: 21 | 22 | - the `check` command, to make sure none of your playlists are broken; all your filepaths still exist 23 | - the `resolve` command, which tries to fix the broken paths by using the [distance between](https://github.com/life4/textdistance) the text 24 | 25 | `resolve` will use the dice coefficient to try and resolve the broken filepath to an existing filepath in your music directory. 26 | 27 | ### Configuration/Installation 28 | 29 | To install, download the two scripts `plainplay`/`resolve_cmd_plainplay` and put it on your `$PATH` somewhere, e.g.: 30 | 31 | ```sh 32 | git clone https://github.com/purarue/plaintext-playlist 33 | cd plaintext-playlist 34 | cp plainplay resolve_cmd_plainplay ~/.local/bin 35 | ``` 36 | 37 | Could also use [`basher`](https://github.com/basherpm/basher): 38 | 39 | ```bash 40 | basher install purarue/plaintext-playlist 41 | ``` 42 | 43 | Requires at least `bash` version 4.0. 44 | 45 | External dependencies: `mpv`, `fzf`, `python3`,(`pip3 install --user -U textdistance pyfzf_iter`), `ffprobe` (installed with `ffmpeg`), `jq` 46 | 47 | This follows 'Progressive Enhancement' with regard to external dependencies; for example, if you never use `resolve`, the corresponding dependency isn't required. 48 | 49 | Stores configuration (playlists) at `PLAINTEXT_PLAYLIST_PLAYLISTS` (defaults to `~/.local/share/plaintext_playlist`). 50 | 51 | You must set `PLAINTEXT_PLAYLIST_MUSIC_DIR` as an environment variable, which defines your 'root' music directory. If you don't have one place you keep all your music, you can set your `$HOME` directory, or `/`, which would cause the playlist files to use absolute paths instead. However, that would make the `resolve` function work very slowly, since it would have to search your entire system to find paths to match broken paths against. 52 | 53 | For `zsh` completion support, see [here](https://purarue.xyz/d/_plainplay). 54 | 55 | ### Basic Scripting 56 | 57 | Since the specification/file format is extremely simple, it integrates nicely with lots of shell tools that work on lines of text. 58 | 59 | To add songs: 60 | 61 | `cd $HOME/Music && find 'Daft Punk' -iname '*fragments of time*.mp3' >> ~/.local/share/plaintext_playlist/electronic.txt` 62 | 63 | To remove songs: 64 | 65 | `sed -i -e '/Fragments/d' "$(plainplay playlistdir)"/electronic.txt` 66 | 67 | Playlists are played through `mpv`, by using the `--playlist` flag, reading from standard input. The equivalent command without `plainplay` would be: 68 | 69 | `cd $HOME/Music && mpv --playlist=- < "$HOME/.local/share/plaintext_playlist/electronic.txt"` 70 | 71 | If I want to selectively play songs from all my playlists, I can do so like: 72 | 73 | ``` 74 | $ cd ~/Music 75 | $ grep -hiE 'mario|runescape|kirby|pokemon' \ 76 | $(find $(plainplay playlistdir) -type f) \ 77 | | shuf | mpv --playlist=- 78 | ``` 79 | 80 | ... which would shuffle songs from my playlists which have paths that match one of `mario|runescape|kirby|pokemon` 81 | 82 | Could instead use `listall` to print all the lines in playlists, then `grep` against those: 83 | 84 | ``` 85 | cd ~/Music && play listall $(plainplay playlistdir)/* | grep -i 'mario' | mpv --shuffle --playlist=- 86 | ``` 87 | 88 | Additionally, since this is just lines of text, you're free to turn the `playlistdir` into a git-tracked directory; I push to a private git repo periodically just so I have this backed up: 89 | 90 | ![](https://raw.githubusercontent.com/purarue/plaintext-playlist/master/.github/playlists_git.png) 91 | 92 | I have lots of aliases I use to selectively play songs from my playlists ([`functions.sh`](./functions.sh)): 93 | 94 | ``` 95 | >>>PMARK 96 | perl -E 'print "`"x3, "bash\n"' 97 | sed -E -e '/^s*$/d' <./functions.sh | awk 'NR > 2' 98 | perl -E 'print "`"x3, "\n"' 99 | ``` 100 | 101 | To create an archive of a playlist, (when in your top-level Music directory) can use tar like: 102 | 103 | `tar -cvf playlist_name.tar -T <(plainplay list )` 104 | 105 | ### Companion Scripts 106 | 107 | As some more complicated examples of what this enables me to do: 108 | 109 | I use `mpv`'s IPC sockets (see my [`mpv-sockets`](https://github.com/purarue/mpv-sockets) scripts) to to send commands to the currently running `mpv` instance. The `mpv-currently-playing` script from there prints the path of the currently playing song. Whenever I'm listening to an album and I want to add a song to a playlist, I do `plainplay curplaying`, it drops me into `fzf` to pick a playlist, and it adds the song that's currently playing to whatever I select. 110 | 111 | [`not-in-playlist`](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/not-in-playlist), which I use to find any albums in my music directory which don't have any songs in any of my playlists, i.e. pick a random album in my music directory I haven't listened to yet. 112 | 113 | #### Syncing music and playlists to my phone 114 | 115 | [`linkmusic`](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/linkmusic) is a `rsync`-like script which creates hardlinks for every file in my playlists into a separate directory (e.g., `~/.local/share/musicsync/`). Then, I use [`syncthing`](https://github.com/syncthing/syncthing) to sync all the songs in my playlists across my computers/onto my phone, without syncing my entire music collection 116 | 117 | On my phone (android), I use [`foobar2000`](https://www.foobar2000.org/apk), which accepts `m3u8` files as playlists. So, using the `plainplay m3u` command, I can [re-create the `m3u8` files](https://purarue.xyz/d/create_playlists.job?dark) in my top-level music directory on my phone, which foobar can then use: 118 | 119 | 120 | 121 | To shuffle the `m3u8` files, I wrote a separate tool [`m3u-shuf`](https://github.com/purarue/m3u-shuf) 122 | 123 | An example of me getting the [music/playlist configuration/paths to work across devices](https://github.com/purarue/dotfiles/blob/23e18977a15b3fa4a968626bd3655a7a2a6c8a88/.profile#L79-L104) (`XDG_MUSIC_DIR` and `PLAINTEXT_PLAYLIST_PLAYLISTS`) 124 | 125 | Python library [here](https://github.com/purarue/plaintext_playlist_py) which has code to glob the `.txt` files from `plaintext-playlist`, as well as a couple other misc scripts, like [validating id3 data](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/id3stuff), or [removing private (amazon/gracenote) id3 frames](https://github.com/purarue/HPI-personal/blob/master/scripts/mpv-clean-priv-frames) using data saved by [`mpv-history-daemon`](https://github.com/purarue/mpv-history-daemon) 126 | 127 | ### Specification 128 | 129 | To clarify, the filenames in each playlist file should have no leading `/`. As an example, if `PLAINTEXT_PLAYLIST_MUSIC_DIR="${HOME}/Music"` and you wanted to add a song at `"${HOME}/Music/ArtistName/AlbumName/Disc2/song.flac"` to the playlist, the corresponding line would be: 130 | 131 | ``` 132 | ArtistName/AlbumName/Disc2/song.flac 133 | ``` 134 | 135 | ... which is then combined to `"${HOME}/Music/ArtistName/AlbumName/Disc2/song.flac"` 136 | 137 | If you don't specify exactly that format, you can run the `check`/`resolve` commands, which will attempt to remove absolute paths/match the closest path and prompt you to update the playlist file. 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # plaintext-playlist 2 | 3 | ![Example Image](./.github/example.png) 4 | 5 | ``` 6 | Usage: plainplay [-h] [-] [OPTIONS] [COMMAND [ARGS]] 7 | 8 | An interactive terminal playlist manager; stores playlists as text files 9 | run without a COMMAND to drop into interactive mode 10 | 11 | [playlist] specifies either the 12 | name (without the location/.txt extension) 13 | or the location of one of the playlists 14 | 15 | curplaying uses my mpv-currently-playing script from 16 | https://github.com/purarue/mpv-sockets 17 | 18 | Additional Flags: 19 | 20 | add: A hyphen (-) can be passed with to instead 21 | receive filenames from stdin. expects filenames to 22 | be in the correct format 23 | (cd to your Music dir and use find for good results) 24 | 25 | e.g.: find somedirectory -name "*.flac" | plainplay - add rock 26 | 27 | resolve: --auto-confirm to automatically 28 | use the closest match instead of prompting you to choose 29 | one of the closest matching files to fix broken filepaths 30 | 31 | m3u: --abs to use absolute paths for the generated m3u file, 32 | instead of paths relative to your Music directory 33 | 34 | m3u: --duration to include the duration in the m3u file 35 | 36 | e.g. plainplay --abs --duration m3u rock 37 | 38 | add [playlist] | Adds one or more songs to a playlist 39 | curplaying [playlist] | Adds a currently playing mpv song to a playlist 40 | remove [playlist] | Removes one of more songs from a playlist 41 | play [playlist] | Play songs from a playlist 42 | playall [playlist]... | Play songs from multiple playlists 43 | shuffle [playlist] | Shuffle songs from a playlist 44 | shuffleall [playlist]... | Shuffle songs from multiple playlists 45 | single [playlist] | Play a single song from a playlist 46 | list [playlist] | List songs in a playlist 47 | listall [playlist]... | List songs from multiple playlists 48 | unique [playlist] | Reduce a playlist to unique songs 49 | exif [playlist] | Displays exif data for items in a playlist 50 | m3u [playlist]... | Create a m3u playlist file from multiple playlists 51 | edit [playlist] | Edit a playlist file with your $EDITOR 52 | playlist-create [playlist] | Creates a new playlist - a playlist file 53 | playlist-delete [playlist] | Delete an existing playlist - a playlist file 54 | playlist-list | List the full paths of each of your playlist files 55 | playlistdir | Print the location of the playlist directory 56 | check | Makes sure that all songs in all your playlists exist 57 | resolve | Attempts to fix broken paths in playlists 58 | ``` 59 | 60 | ## Rationale 61 | 62 | I wanted a minimal, scriptable-friendly playlist for my local music, without having to rely on a third party playlist manager/GUI interface. 63 | 64 | _This stores playlists as text files, one per playlist, where each line is the (relative) path to a song in the playlist._ 65 | 66 | This includes a [`fzf`](https://github.com/junegunn/fzf) backed interactive mode. If you don't provide a playlist, this drops into interactive mode, letting you fuzzy match against playlist names, select multiple songs to add/remove, or select playlists to play/shuffle from. 67 | 68 | This only stores the relative filepath to your base music directory in each file, so you could move your music directory somewhere else and update the environment variable, and everything works, even across computers. However, filenames tend to change, and sometimes you might change the name of an artists' folder, or the name of an album to include metadata. So, `plainplay` has commands to help fix that: 69 | 70 | - the `check` command, to make sure none of your playlists are broken; all your filepaths still exist 71 | - the `resolve` command, which tries to fix the broken paths by using the [distance between](https://github.com/life4/textdistance) the text 72 | 73 | `resolve` will use the dice coefficient to try and resolve the broken filepath to an existing filepath in your music directory. 74 | 75 | ### Configuration/Installation 76 | 77 | To install, download the two scripts `plainplay`/`resolve_cmd_plainplay` and put it on your `$PATH` somewhere, e.g.: 78 | 79 | ```sh 80 | git clone https://github.com/purarue/plaintext-playlist 81 | cd plaintext-playlist 82 | cp plainplay resolve_cmd_plainplay ~/.local/bin 83 | ``` 84 | 85 | Could also use [`basher`](https://github.com/basherpm/basher): 86 | 87 | ```bash 88 | basher install purarue/plaintext-playlist 89 | ``` 90 | 91 | Requires at least `bash` version 4.0. 92 | 93 | External dependencies: `mpv`, `fzf`, `python3`,(`pip3 install --user -U textdistance pyfzf_iter`), `ffprobe` (installed with `ffmpeg`), `jq` 94 | 95 | This follows 'Progressive Enhancement' with regard to external dependencies; for example, if you never use `resolve`, the corresponding dependency isn't required. 96 | 97 | Stores configuration (playlists) at `PLAINTEXT_PLAYLIST_PLAYLISTS` (defaults to `~/.local/share/plaintext_playlist`). 98 | 99 | You must set `PLAINTEXT_PLAYLIST_MUSIC_DIR` as an environment variable, which defines your 'root' music directory. If you don't have one place you keep all your music, you can set your `$HOME` directory, or `/`, which would cause the playlist files to use absolute paths instead. However, that would make the `resolve` function work very slowly, since it would have to search your entire system to find paths to match broken paths against. 100 | 101 | For `zsh` completion support, see [here](https://purarue.xyz/d/_plainplay). 102 | 103 | ### Basic Scripting 104 | 105 | Since the specification/file format is extremely simple, it integrates nicely with lots of shell tools that work on lines of text. 106 | 107 | To add songs: 108 | 109 | `cd $HOME/Music && find 'Daft Punk' -iname '*fragments of time*.mp3' >> ~/.local/share/plaintext_playlist/electronic.txt` 110 | 111 | To remove songs: 112 | 113 | `sed -i -e '/Fragments/d' "$(plainplay playlistdir)"/electronic.txt` 114 | 115 | Playlists are played through `mpv`, by using the `--playlist` flag, reading from standard input. The equivalent command without `plainplay` would be: 116 | 117 | `cd $HOME/Music && mpv --playlist=- < "$HOME/.local/share/plaintext_playlist/electronic.txt"` 118 | 119 | If I want to selectively play songs from all my playlists, I can do so like: 120 | 121 | ``` 122 | $ cd ~/Music 123 | $ grep -hiE 'mario|runescape|kirby|pokemon' \ 124 | $(find $(plainplay playlistdir) -type f) \ 125 | | shuf | mpv --playlist=- 126 | ``` 127 | 128 | ... which would shuffle songs from my playlists which have paths that match one of `mario|runescape|kirby|pokemon` 129 | 130 | Could instead use `listall` to print all the lines in playlists, then `grep` against those: 131 | 132 | ``` 133 | cd ~/Music && play listall $(plainplay playlistdir)/* | grep -i 'mario' | mpv --shuffle --playlist=- 134 | ``` 135 | 136 | Additionally, since this is just lines of text, you're free to turn the `playlistdir` into a git-tracked directory; I push to a private git repo periodically just so I have this backed up: 137 | 138 | ![](https://raw.githubusercontent.com/purarue/plaintext-playlist/master/.github/playlists_git.png) 139 | 140 | I have lots of aliases I use to selectively play songs from my playlists ([`functions.sh`](./functions.sh)): 141 | 142 | ```bash 143 | # --msg-level=file=error removes the 'reading from stdin...' info message 144 | alias mpv-from-stdin='mpv --playlist=- --no-audio-display --msg-level=file=error' 145 | alias mpv-shuffle='mpv-from-stdin --shuffle' 146 | # change directory to music/playlist directories 147 | alias cm='cd "${PLAINTEXT_PLAYLIST_MUSIC_DIR:-${XDG_MUSIC_DIR:-"${HOME}/Music"}}"' 148 | alias cdpl='cd "${PLAINTEXT_PLAYLIST_PLAYLISTS}"' 149 | # shorthands 150 | alias play='plainplay' 151 | alias pplay='plainplay play' 152 | alias splay='plainplay shuffle' 153 | alias splayall='fd . "$PLAINTEXT_PLAYLIST_PLAYLISTS" -X plainplay shuffleall' 154 | # list/play all music that matches 'rg' pattern 155 | playrg_f() { 156 | cm 157 | # https://github.com/purarue/pura-utils/blob/main/shellscripts/unique 158 | fd . "$(plainplay playlistdir)" --type file -X cat | unique | rg -i "$*" 159 | } 160 | # play all paths that match whatever I pass as positional arguments 161 | playrg-_f() { 162 | cm 163 | playrg_f "$*" | mpv-from-stdin 164 | } 165 | # use aliases so that the 'cd' actually changes directory in the shell 166 | alias playrg='cm; playrg_f' 167 | alias 'playrg-=cm; playrg-_f' 168 | # fzf to play music 169 | alias playfzf='cm; rg --color never --with-filename --no-heading "" "${PLAINTEXT_PLAYLIST_PLAYLISTS}/"*.txt | sed -e "s|^${PLAINTEXT_PLAYLIST_PLAYLISTS}/||" | fzf' 170 | alias 'playfzf-=playfzf | cut -d":" -f2- | mpv-from-stdin' 171 | ``` 172 | 173 | To create an archive of a playlist, (when in your top-level Music directory) can use tar like: 174 | 175 | `tar -cvf playlist_name.tar -T <(plainplay list )` 176 | 177 | ### Companion Scripts 178 | 179 | As some more complicated examples of what this enables me to do: 180 | 181 | I use `mpv`'s IPC sockets (see my [`mpv-sockets`](https://github.com/purarue/mpv-sockets) scripts) to to send commands to the currently running `mpv` instance. The `mpv-currently-playing` script from there prints the path of the currently playing song. Whenever I'm listening to an album and I want to add a song to a playlist, I do `plainplay curplaying`, it drops me into `fzf` to pick a playlist, and it adds the song that's currently playing to whatever I select. 182 | 183 | [`not-in-playlist`](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/not-in-playlist), which I use to find any albums in my music directory which don't have any songs in any of my playlists, i.e. pick a random album in my music directory I haven't listened to yet. 184 | 185 | #### Syncing music and playlists to my phone 186 | 187 | [`linkmusic`](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/linkmusic) is a `rsync`-like script which creates hardlinks for every file in my playlists into a separate directory (e.g., `~/.local/share/musicsync/`). Then, I use [`syncthing`](https://github.com/syncthing/syncthing) to sync all the songs in my playlists across my computers/onto my phone, without syncing my entire music collection 188 | 189 | On my phone (android), I use [`foobar2000`](https://www.foobar2000.org/apk), which accepts `m3u8` files as playlists. So, using the `plainplay m3u` command, I can [re-create the `m3u8` files](https://purarue.xyz/d/create_playlists.job?dark) in my top-level music directory on my phone, which foobar can then use: 190 | 191 | 192 | 193 | To shuffle the `m3u8` files, I wrote a separate tool [`m3u-shuf`](https://github.com/purarue/m3u-shuf) 194 | 195 | An example of me getting the [music/playlist configuration/paths to work across devices](https://github.com/purarue/dotfiles/blob/23e18977a15b3fa4a968626bd3655a7a2a6c8a88/.profile#L79-L104) (`XDG_MUSIC_DIR` and `PLAINTEXT_PLAYLIST_PLAYLISTS`) 196 | 197 | Python library [here](https://github.com/purarue/plaintext_playlist_py) which has code to glob the `.txt` files from `plaintext-playlist`, as well as a couple other misc scripts, like [validating id3 data](https://github.com/purarue/plaintext_playlist_py/blob/master/bin/id3stuff), or [removing private (amazon/gracenote) id3 frames](https://github.com/purarue/HPI-personal/blob/master/scripts/mpv-clean-priv-frames) using data saved by [`mpv-history-daemon`](https://github.com/purarue/mpv-history-daemon) 198 | 199 | ### Specification 200 | 201 | To clarify, the filenames in each playlist file should have no leading `/`. As an example, if `PLAINTEXT_PLAYLIST_MUSIC_DIR="${HOME}/Music"` and you wanted to add a song at `"${HOME}/Music/ArtistName/AlbumName/Disc2/song.flac"` to the playlist, the corresponding line would be: 202 | 203 | ``` 204 | ArtistName/AlbumName/Disc2/song.flac 205 | ``` 206 | 207 | ... which is then combined to `"${HOME}/Music/ArtistName/AlbumName/Disc2/song.flac"` 208 | 209 | If you don't specify exactly that format, you can run the `check`/`resolve` commands, which will attempt to remove absolute paths/match the closest path and prompt you to update the playlist file. 210 | -------------------------------------------------------------------------------- /functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | # https://github.com/purarue/plaintext-playlist.git 3 | # --msg-level=file=error removes the 'reading from stdin...' info message 4 | alias mpv-from-stdin='mpv --playlist=- --no-audio-display --msg-level=file=error' 5 | alias mpv-shuffle='mpv-from-stdin --shuffle' 6 | # change directory to music/playlist directories 7 | alias cm='cd "${PLAINTEXT_PLAYLIST_MUSIC_DIR:-${XDG_MUSIC_DIR:-"${HOME}/Music"}}"' 8 | alias cdpl='cd "${PLAINTEXT_PLAYLIST_PLAYLISTS}"' 9 | # shorthands 10 | alias play='plainplay' 11 | alias pplay='plainplay play' 12 | alias splay='plainplay shuffle' 13 | alias splayall='fd . "$PLAINTEXT_PLAYLIST_PLAYLISTS" -X plainplay shuffleall' 14 | # list/play all music that matches 'rg' pattern 15 | playrg_f() { 16 | cm 17 | # https://github.com/purarue/pura-utils/blob/main/shellscripts/unique 18 | fd . "$(plainplay playlistdir)" --type file -X cat | unique | rg -i "$*" 19 | } 20 | # play all paths that match whatever I pass as positional arguments 21 | playrg-_f() { 22 | cm 23 | playrg_f "$*" | mpv-from-stdin 24 | } 25 | # use aliases so that the 'cd' actually changes directory in the shell 26 | alias playrg='cm; playrg_f' 27 | alias 'playrg-=cm; playrg-_f' 28 | # fzf to play music 29 | alias playfzf='cm; rg --color never --with-filename --no-heading "" "${PLAINTEXT_PLAYLIST_PLAYLISTS}/"*.txt | sed -e "s|^${PLAINTEXT_PLAYLIST_PLAYLISTS}/||" | fzf' 30 | alias 'playfzf-=playfzf | cut -d":" -f2- | mpv-from-stdin' 31 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | pretty = True 3 | disallow_any_generics = False 4 | show_error_context = True 5 | show_error_codes = True 6 | namespace_packages = True 7 | disallow_subclassing_any = True 8 | disallow_incomplete_defs = True 9 | no_implicit_optional = True 10 | warn_redundant_casts = True 11 | warn_return_any = True 12 | warn_unreachable = True 13 | -------------------------------------------------------------------------------- /plainplay: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # plainplay, an script to maintain plaintext playlists 3 | # run plainplay -h for help 4 | # see https://github.com/purarue/plaintext-playlist 5 | # 6 | # For reference, variable styling is: 7 | # ENVIRONMENT_VARIABLES 8 | # GlobalVariables 9 | # temporaryVariables 10 | 11 | # Global Variables 12 | declare ConfDir= 13 | declare MusicDir= 14 | # defaults to fzf interactive if not set 15 | declare ReadInputType=MULTI_SELECT_FZF 16 | declare ResolveAutoConfirm= 17 | declare M3uMode=RELATIVE 18 | declare M3uIncludeDuration=0 19 | declare Action= 20 | declare ActivePlaylist= 21 | # used for shuffleall/playall 22 | declare -a AllActivePlaylists=() 23 | 24 | readonly PromptHistoryFile="${PLAINTEXT_PLAYLIST_HISTORY:-${XDG_CACHE_HOME:-${HOME}/.cache}}/plaintext_playlist_history.txt" 25 | 26 | # set default fzf command 27 | export FZF_DEFAULT_COMMAND="find -type f | sort" 28 | 29 | eprintf() { 30 | # shellcheck disable=SC2059 31 | printf "$@" 1>&2 32 | } 33 | 34 | # error helper 35 | abort() { 36 | eprintf '%s\n' "$1" 37 | exit 1 38 | } 39 | 40 | # require that a executable be installed 41 | require() { 42 | [[ $(command -v "$1") ]] || abort "This part of the application requires '$1', could not find it on your \$PATH" 43 | } 44 | require realpath 45 | require sed 46 | 47 | # HELP RELATED 48 | 49 | # A list of commands 50 | commands_list() { 51 | cat <&2 207 | print_help 1 208 | ;; 209 | esac 210 | ;; 211 | esac 212 | shift # remove current item from args 213 | done 214 | # if nothing was passed by the user 215 | [[ -z "$Action" ]] && pick_command_interactive 216 | } 217 | 218 | # prompts the user with the list of commands, and lets them select one of them 219 | pick_command_interactive() { 220 | local rawFzfChoice firstArg 221 | require fzf 222 | rawFzfChoice="$(commands_list | fzf -i --prompt='Run > ' --history="${PromptHistoryFile}")" || abort "Error: You didn't provide a valid command" 223 | # get the first argument (e.g. add|remove|playlist-list) 224 | firstArg=("${rawFzfChoice%% *| *}") 225 | parse_args "${firstArg[@]}" 226 | } 227 | 228 | # returns the playlist path for a path/name if it exists. 229 | # If -x is provided as the second argument, exits if playlist path doesn't exist 230 | get_playlist_path() { 231 | local fullPath 232 | fullPath="$(realpath "$1")" 233 | # if the full path exists 234 | if [[ -e "${fullPath}" ]]; then 235 | echo -e "${fullPath}" 236 | # else if the file is just a name (e.g. rock{,.txt} for $ConfDir/rock.txt) 237 | else 238 | fullPath="${ConfDir}/${1%.txt}.txt" 239 | if [[ -f "${fullPath}" ]]; then 240 | echo -e "${fullPath}" 241 | else 242 | # if not told to exit, prints the path, even though the path may not exist 243 | if [[ "$2" = "-x" ]]; then 244 | abort "Error: Could not find a matching playlist for: ${1}" 245 | else 246 | echo -e "${fullPath}" 247 | fi 248 | fi 249 | fi 250 | } 251 | 252 | make_sure_playlists_exist() { 253 | local playlist_count 254 | playlist_count="$(command ls -1 "${ConfDir}" | wc -l)" 255 | ((playlist_count == 0)) && abort "Error: No playlists exist. Create one before trying to select one" 256 | } 257 | 258 | # presents the user with a prompt of playlists to choose from 259 | pick_existing_playlist() { 260 | local playlistName 261 | require fzf 262 | make_sure_playlists_exist 263 | playlistName="$(list_playlists | fzf -i --prompt='Select a playlist > ' --preview="cat \"${ConfDir}/\"{}.txt")" || abort "Error: You didn't select a valid playlist." 264 | echo -e "${playlistName}" 265 | } 266 | 267 | # sets AllActivePlaylists to whatever the user selects 268 | pick_multiple_existing_playlists() { 269 | local playlistNames 270 | require fzf 271 | make_sure_playlists_exist 272 | playlistNames="$(list_playlists | fzf -m -i --prompt="Select multiple playlists by using 'Tab' > " --preview="cat \"${ConfDir}/\"{}.txt")" || abort "Error: You didn't select a valid playlist." 273 | readarray -t <<<"${playlistNames}" 274 | AllActivePlaylists=("${MAPFILE[@]}") 275 | } 276 | 277 | # lists the names of the playlists (without .txt) 278 | list_playlists() { 279 | command ls -1 "${ConfDir}" | grep -i ".txt$" | sed -e "s/\.txt//g" 280 | } 281 | 282 | # ask the user for some input, hit enter to continue. "$1" is the prompt string 283 | generic_input() { 284 | read -e -p "$1" -r reply 285 | printf '%s\n' "${reply}" 286 | } 287 | 288 | # receives lines of text, each a path - resolves to an absolute path 289 | list_to_absolute() { 290 | while read -r line; do 291 | realpath "${line}" 292 | done 293 | } 294 | 295 | # uses the current directory plus passed filenames to resolve relative paths 296 | convert_to_playlist_filenames() { 297 | local relativeFilenames absoluteFilenames 298 | relativeFilenames="$(cat)" 299 | # get absolute path of all of the music to be added 300 | absoluteFilenames="$(list_to_absolute <<<"${relativeFilenames}")" 301 | # make sure user gave data 302 | [[ -z "${absoluteFilenames}" ]] && abort "Did not receive any filenames to add" 303 | echo -e "${absoluteFilenames}" | remove_music_dir 304 | } 305 | 306 | # converts absolute paths to paths relative to the music dir 307 | remove_music_dir() { 308 | local absoluteNoLinkFilename 309 | # if the $MusicDir is a link, (e.g. linked to some other drive) 310 | # also replace that path 311 | absoluteNoLinkFilename="$(realpath "${MusicDir}")" 312 | # convert to relative filenames: 313 | # (receives input from STDIN) 314 | sed -e "s|^${MusicDir%/}/||" -e "s|^${absoluteNoLinkFilename%/}/||" 315 | } 316 | 317 | # checks a playlist file for broken paths 318 | # this prints in vim quickfix compatible format, so you could do: 319 | # nvim -q <(plainplay check) 320 | # if there were any errors and then use the quickfix (':help quickfix') 321 | # to cycle through all of them 322 | check_playlist() { 323 | local playlistToCheck ret songFullPath line 324 | playlistToCheck="$1" 325 | ret=0 326 | line=1 327 | # printf "Checking '%s'...\n" "${playlistToCheck}" 328 | while read -r song; do 329 | songFullPath="${MusicDir%/}/${song}" 330 | [[ -e "${songFullPath}" ]] || { 331 | ret=1 332 | printf "%s:%d: '%s' doesn't exist\n" "$playlistToCheck" "$line" "${songFullPath}" 333 | } 334 | [[ -d "${songFullPath}" ]] && { 335 | ret=1 336 | printf "%s:%d: '%s' is a directory\n" "$playlistToCheck" "$line" "${songFullPath}" 337 | } 338 | # check if file is a common image format 339 | [[ "${song}" =~ \.(jpg|jpeg|png|webp)$ ]] && { 340 | ret=1 341 | printf "%s:%d: '%s' is an image\n" "$playlistToCheck" "$line" "${songFullPath}" 342 | } 343 | ((line++)) 344 | done <"${playlistToCheck}" 345 | return "${ret}" 346 | } 347 | 348 | # safely concatenates the files in AllActivePlaylists, prints the result to STDOUT 349 | # in case files dont end properly with a newline, this adds an extra line 350 | # to the end, and then removes it if it was empty 351 | safe_concat() { 352 | { 353 | for pfile in "${AllActivePlaylists[@]}"; do 354 | cat "${pfile}" 355 | echo 356 | done 357 | } | sed -e "/^\s*$/d" 358 | } 359 | 360 | IMAGE_EXTENSIONS=('jpg' 'jpeg' 'png' 'webp') 361 | 362 | find_no_images() { 363 | local args=() 364 | for ind in "${!IMAGE_EXTENSIONS[@]}"; do 365 | args+=(-iname "*.${IMAGE_EXTENSIONS[ind]}") 366 | ((ind < ${#IMAGE_EXTENSIONS[@]} - 1)) && args+=(-o) 367 | done 368 | find . -type f \! \( "${args[@]}" \) 369 | } 370 | 371 | # "main" 372 | run_plaintext_playlist() { 373 | # Validate Playlist based on given arguments 374 | # Prompt user if necessary 375 | case "$Action" in 376 | ADD | CURPLAYING | REMOVE | LIST | UNIQUE | EXIF | EDIT | PLAY | SHUFFLE | SINGLE | PLAYLIST_DELETE) 377 | if [[ -z "${ActivePlaylist}" ]]; then 378 | ActivePlaylist="$(pick_existing_playlist)" || exit 1 379 | fi 380 | ActivePlaylist="$(get_playlist_path "${ActivePlaylist}" -x)" || exit 1 381 | ;; 382 | PLAYALL | SHUFFLEALL | LISTALL | M3U) 383 | # if no playlist at all was selected 384 | if [[ -z "${ActivePlaylist}" ]]; then 385 | pick_multiple_existing_playlists || exit 1 386 | fi 387 | # convert each item in the array to a path 388 | local tempResult=() 389 | for plist in "${AllActivePlaylists[@]}"; do 390 | tempResult+=("$(get_playlist_path "${plist}" -x)") || exit 1 391 | done 392 | AllActivePlaylists=("${tempResult[@]}") 393 | ;; 394 | 395 | PLAYLIST_CREATE) 396 | if [[ -z "${ActivePlaylist}" ]]; then 397 | ActivePlaylist="$(get_playlist_path "$(generic_input 'Name of new playlist: ')")" || exit 1 398 | fi 399 | ;; 400 | esac 401 | 402 | # printf "%s\n" "$Action" 403 | # printf "%s\n" "$ActivePlaylist" 404 | # printf "%s\n" "$ReadInputType" 405 | # Run, based on Action 406 | case "$Action" in 407 | PLAYLIST_CREATE) 408 | if [[ -f "${ActivePlaylist}" ]]; then 409 | printf "Warning: Playlist '%s' already exists.\n" "${ActivePlaylist}" 1>&2 410 | else 411 | touch "${ActivePlaylist}" 412 | printf "Created Playlist: '%s'\n" "${ActivePlaylist}" 413 | fi 414 | ;; 415 | PLAYLIST_LIST) 416 | find "${ConfDir}" -type f -iname "*.txt" 417 | ;; 418 | 419 | PLAYLIST_DELETE) 420 | printf "Are you sure you want to delete '%s'? [y/N] " "${ActivePlaylist}" 421 | read -r response 422 | if [[ $response =~ ^[Yy] ]]; then 423 | rm "${ActivePlaylist}" 424 | printf "Deleted Playlist: '%s'\n" "${ActivePlaylist}" 425 | fi 426 | ;; 427 | PLAYLISTDIR) 428 | echo "${ConfDir}" 429 | ;; 430 | CHECK) 431 | ret=0 432 | while IFS= read -r -d '' playlistToCheck; do 433 | check_playlist "${playlistToCheck}" || ret=1 434 | done < <(find "${ConfDir}" -type f -iname "*.txt" -print0) 435 | exit "${ret}" 436 | ;; 437 | RESOLVE) 438 | require resolve_cmd_plainplay 439 | require python3 440 | resolve_cmd_plainplay "${ConfDir}" "${MusicDir}" "${ResolveAutoConfirm}" 441 | ;; 442 | ADD) 443 | case "${ReadInputType}" in 444 | FROM_STDIN) 445 | filenamesToAdd="$(cat)" 446 | echo -e "${filenamesToAdd}" >>"${ActivePlaylist}" 447 | ;; 448 | 449 | MULTI_SELECT_FZF) 450 | require fzf 451 | songsToAdd="$(find_no_images | fzf -m -i --prompt="Select songs to add. Hit 'Tab' to select multiple > ")" || abort "Error: You didn't provide any songs to add" 452 | fixedFilenames="$(convert_to_playlist_filenames <<<"${songsToAdd}")" 453 | echo -e "Adding the following to ${ActivePlaylist}:" 454 | echo -e "${fixedFilenames}" 455 | echo -e "${fixedFilenames}" >>"${ActivePlaylist}" 456 | ;; 457 | *) 458 | abort "Unrecognized selection type. Expected FROM_STDIN or MULTI_SELECT_FZF" 459 | ;; 460 | 461 | esac 462 | ;; 463 | CURPLAYING) 464 | require mpv-currently-playing 465 | # get currently playing songs, limit to items which aren't paused 466 | curPlayingSongs="$(mpv-currently-playing 2>/dev/null)" 467 | curPlayingSongsCount="$(wc -l <<<"${curPlayingSongs}")" 468 | [[ -z "${curPlayingSongs}" ]] && abort "Did not receive any paths from active mpv instances" 469 | # if there's only one playing, select that, else fzf to select one of the playing songs 470 | if ((curPlayingSongsCount == 1)); then 471 | chosenSong="${curPlayingSongs}" 472 | else 473 | require fzf 474 | chosenSong="$(fzf -m -i --prompt="Select which currently playing song to add > " <<<"${curPlayingSongs}")" || abort "You didn't select one of the songs" 475 | fi 476 | relativeFileName="$(remove_music_dir <<<"${chosenSong}")" 477 | echo -e "Adding currently playing song to ${ActivePlaylist}" 478 | echo -e "${relativeFileName}" 479 | echo -e "${relativeFileName}" >>"${ActivePlaylist}" 480 | ;; 481 | REMOVE) 482 | require fzf 483 | linesToRemove="$(fzf -m -i --prompt="Select songs to remove. Hit 'Tab' to select multiple > " <"${ActivePlaylist}")" || abort "Error: you didn't provide any songs to remove..." 484 | filteredSongs="$(grep -Fxv "${linesToRemove}" <"${ActivePlaylist}")" 485 | echo -e "${filteredSongs}" >"${ActivePlaylist}" 486 | echo -e "Removed the following from ${ActivePlaylist}:" 487 | echo -e "${linesToRemove}" 488 | ;; 489 | EDIT) 490 | "${EDITOR:-${VISUAL:-'vim'}}" "${ActivePlaylist}" 491 | ;; 492 | LIST) 493 | cat "${ActivePlaylist}" 494 | ;; 495 | LISTALL) 496 | cd "${MusicDir}" && safe_concat 497 | ;; 498 | M3U) 499 | if ((M3uIncludeDuration)); then 500 | require jq 501 | require ffprobe 502 | fi 503 | local -a filepaths=() 504 | while read -r song; do 505 | if [[ "${M3uMode}" == 'ABSOLUTE' ]]; then 506 | filepaths+=("${MusicDir%/}/${song}") 507 | else 508 | filepaths+=("${song}") 509 | fi 510 | done < <(cd "${MusicDir}" && safe_concat) 511 | printf '#EXTM3U\n' 512 | if ((M3uIncludeDuration)); then 513 | for fp in "${filepaths[@]}"; do 514 | metadataJson="$(cd "$MusicDir" && ffprobe -v quiet -print_format json -show_format "${fp}")" || { 515 | echo -e "ffprobe failed for ${fp}" 1>&2 516 | continue 517 | } 518 | durationFloat="$(jq -r '.format.duration' <<<"${metadataJson}")" 519 | durationInt="${durationFloat%%.*}" 520 | artistName="$(jq -r '.format.tags.artist' <<<"${metadataJson}")" 521 | trackName="$(jq -r '.format.tags.title' <<<"${metadataJson}")" 522 | printf '#EXTINF:%d' "${durationInt}" 523 | # if we got both a artist and track name, include that in the metadata 524 | if [[ "${artistName}" == 'null' || "${trackName}" == 'null' ]]; then 525 | # no additional metadata, just print a newline 526 | printf '\n' 527 | else 528 | printf ',%s - %s\n' "${artistName}" "${trackName}" 529 | fi 530 | printf '%s\n' "${fp}" 531 | done 532 | else 533 | # just print the lines 534 | for fp in "${filepaths[@]}"; do 535 | printf '%s\n' "${fp}" 536 | done 537 | fi 538 | ;; 539 | UNIQUE) 540 | require uniq 541 | require mktemp 542 | filteredPlaylistFile="$(mktemp)" 543 | beforeCount="$(wc -l <"${ActivePlaylist}")" 544 | cat -n "${ActivePlaylist}" | sort -uk2 | sort -nk1 | cut -f2- >"${filteredPlaylistFile}" 545 | mv "${filteredPlaylistFile}" "${ActivePlaylist}" 546 | printf "Filtered '%s' to unique lines. (%d before, %d now)\n" "${ActivePlaylist}" "${beforeCount}" "$(wc -l <"${ActivePlaylist}")" 547 | ;; 548 | EXIF) 549 | require exiftool 550 | filepath="$(cd "${MusicDir}" && fzf +m -i --prompt="Print exiftool for > " --preview="cd \"${MusicDir}\" && exiftool {}" <"${ActivePlaylist}")" || abort "No filepath selected to print exif data for." 551 | cd "${MusicDir}" && exiftool "${filepath}" 552 | ;; 553 | PLAY) 554 | require mpv 555 | cd "${MusicDir}" && mpv --playlist=- --no-audio-display <"${ActivePlaylist}" 556 | ;; 557 | 558 | PLAYALL) 559 | require mpv 560 | cd "${MusicDir}" && safe_concat | mpv --playlist=- --no-audio-display 561 | ;; 562 | 563 | SHUFFLE) 564 | require mpv 565 | cd "${MusicDir}" && mpv --shuffle --playlist=- --no-audio-display <"${ActivePlaylist}" 566 | ;; 567 | 568 | SHUFFLEALL) 569 | require mpv 570 | cd "${MusicDir}" && safe_concat | mpv --playlist=- --shuffle --no-audio-display 571 | ;; 572 | 573 | SINGLE) 574 | require mpv 575 | require fzf 576 | if chosenSong="$(fzf -i --prompt='Select a song to play > ' <"${ActivePlaylist}")"; then 577 | cd "${MusicDir}" && mpv --playlist=- --no-audio-display <<<"${chosenSong}" 578 | else 579 | printf "Error: Didn't receive single to play\n" >&2 580 | fi 581 | ;; 582 | 583 | *) 584 | abort "Unexpected Error. Could not find action ${Action}" 585 | ;; 586 | 587 | esac 588 | } 589 | 590 | check_for_help "$@" 591 | application_setup 592 | parse_args "$@" 593 | run_plaintext_playlist "${Action}" "${ActivePlaylist}" || exit 1 594 | -------------------------------------------------------------------------------- /resolve_cmd_plainplay: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | 6 | from glob import glob 7 | from typing import List, Tuple, Dict, Optional, Callable 8 | 9 | try: 10 | import textdistance # type: ignore[import] 11 | from pyfzf import FzfPrompt # type: ignore[import] 12 | except ImportError: 13 | print( 14 | "Could not find the 'textdistance' or 'pyfzf_iter' package. Run 'pip3 install --user -U textdistance pyfzf_iter' to install it.", 15 | file=sys.stderr, 16 | ) 17 | sys.exit(1) 18 | 19 | playlist_dir: Optional[str] = None 20 | music_dir: Optional[str] = None 21 | all_filepaths: Optional[List[str]] = None 22 | auto_confirm: bool = False 23 | 24 | 25 | def read_playlist_file(playlist_file: str) -> List[str]: 26 | """ 27 | Reads a playlist file into a list of filepaths 28 | """ 29 | with open(playlist_file, "r") as playlist_f: 30 | return [line for line in playlist_f.read().splitlines() if line.strip()] 31 | 32 | 33 | def get_music_dir_files(): 34 | """ 35 | If it hasn't already been indexed, get absolute paths 36 | for each item in 'music_directory' 37 | """ 38 | assert music_dir is not None 39 | global all_filepaths 40 | if all_filepaths is None: 41 | print("Scanning '{}' for filepaths to compare files against".format(music_dir)) 42 | all_filepaths = [] 43 | for dirpath, _, filenames in os.walk(music_dir, followlinks=True): 44 | for f in filenames: 45 | all_filepaths.append(os.path.abspath(os.path.join(dirpath, f))) 46 | return all_filepaths 47 | 48 | 49 | def check_playlist(playlist_file: str) -> List[Tuple[int, str]]: 50 | """ 51 | Checks a playlist file for broken files, returns a list 52 | of them if any exist. 53 | """ 54 | assert music_dir is not None 55 | print("Resolving '{}'...".format(playlist_file)) 56 | broken_songpaths: List[Tuple[int, str]] = [] 57 | songs = read_playlist_file(playlist_file) 58 | for i, song in enumerate(songs): 59 | song_filepath = os.path.join(music_dir, song) 60 | if not os.path.exists(song_filepath): 61 | broken_songpaths.append((i, song_filepath)) 62 | return broken_songpaths 63 | 64 | 65 | def extension_matches(pth1: str, pth2: str) -> bool: 66 | _, pth1_ext = os.path.splitext(pth1) 67 | _, pth2_ext = os.path.splitext(pth2) 68 | return pth1_ext == pth2_ext 69 | 70 | 71 | def fix_filepath(broken_songpath: str) -> Callable[[], str]: 72 | """ 73 | Uses the text distance to try and fix a broken songpath, 74 | prompts the user if --auto-confirm wasn't passed 75 | Returns the new filepath. 76 | """ 77 | all_filepaths = get_music_dir_files() 78 | print(f"calculating scores for {broken_songpath}...", file=sys.stderr) 79 | similarity_scores: List[Tuple[str, int]] = [ 80 | ( 81 | filepath, 82 | textdistance.algorithms.damerau_levenshtein(broken_songpath, filepath), 83 | ) 84 | for filepath in all_filepaths 85 | if extension_matches(broken_songpath, filepath) 86 | ] 87 | similarity_scores.sort(key=lambda item: item[1], reverse=False) 88 | if auto_confirm: 89 | return lambda: similarity_scores[0][0] 90 | else: 91 | bpath = broken_songpath.replace("'", "") 92 | query = os.path.basename(bpath.casefold()) 93 | kwargs = {} 94 | if "QUERY" in os.environ: 95 | kwargs["query"] = query 96 | 97 | def _call_fzf() -> str: 98 | replace_with_filepath_res = FzfPrompt().prompt( 99 | [sc[0] for sc in similarity_scores], 100 | '--prompt="{} with > "'.format(bpath), 101 | **kwargs, 102 | ) 103 | if len(replace_with_filepath_res) == 0: 104 | print( 105 | f"Warning: didn't receive any response for {broken_songpath}", 106 | file=sys.stderr, 107 | ) 108 | raise RuntimeError 109 | assert ( 110 | len(replace_with_filepath_res) == 1 111 | ), f"Expected 1 result from fzf query, received '{replace_with_filepath_res}'" 112 | return replace_with_filepath_res[0] 113 | 114 | return _call_fzf 115 | 116 | 117 | def replace_fixed_filepaths_and_write( 118 | replacements: Dict[int, str], filename: str 119 | ) -> None: 120 | """ 121 | Replaces items that have been resolved, writes back to the playlist file 122 | if there are changes 123 | """ 124 | assert music_dir is not None 125 | if len(replacements.keys()): 126 | playlist_lines = read_playlist_file(filename) 127 | for lineno, replace_with in replacements.items(): 128 | _, _, relative_filepath = replace_with.partition(music_dir) 129 | relative_filepath = relative_filepath.lstrip("/") 130 | print( 131 | "{} -> {}".format(playlist_lines[lineno], relative_filepath), 132 | file=sys.stderr, 133 | ) 134 | playlist_lines[lineno] = relative_filepath 135 | with open(filename, "w") as playlist_f: 136 | playlist_f.write("\n".join(playlist_lines)) 137 | playlist_f.write("\n") # write newline for last item in file 138 | print("Wrote replacements to", filename) 139 | 140 | 141 | def enumerate_playlists(playlist_dir: str) -> List[str]: 142 | """ 143 | Returns a list of the playlist .txt files 144 | """ 145 | return sorted( 146 | glob("{}/*.txt".format(playlist_dir.rstrip("/"))), 147 | key=lambda pl: os.stat(pl).st_size, 148 | ) 149 | 150 | 151 | def main(cmd_args: List[str]) -> int: 152 | global playlist_dir, music_dir, auto_confirm 153 | if len(cmd_args) < 2: 154 | print( 155 | "Must pass the location of the playlist directory and the music directory as the first two arguments", 156 | file=sys.stderr, 157 | ) 158 | return 1 159 | playlist_dir, music_dir, *rest = cmd_args 160 | auto_confirm = "--auto-confirm" in rest 161 | for playlist in enumerate_playlists(playlist_dir): 162 | playlist_replacements: Dict[int, Callable[[], str]] = {} 163 | for i, broken_songpath in check_playlist(playlist): 164 | playlist_replacements[i] = fix_filepath(broken_songpath) 165 | replace_fixed_filepaths_and_write( 166 | {i: fn() for i, fn in playlist_replacements.items()}, playlist 167 | ) 168 | return 0 169 | 170 | 171 | if __name__ == "__main__": 172 | sys.exit(main(sys.argv[1:])) 173 | --------------------------------------------------------------------------------