├── docs ├── mkdocs.yml └── docs │ ├── edit.png │ ├── main.png │ ├── pref.png │ ├── batch_tag_edit.png │ ├── batch_tag_main.png │ ├── main_annotated.png │ ├── tag_preview_default.png │ └── index.md ├── clid ├── __init__.py ├── version.py ├── database │ ├── __init__.py │ ├── mp3db.py │ └── prefdb.py ├── forms │ ├── __init__.py │ ├── editmeta.py │ ├── pref.py │ └── main.py ├── base │ ├── __init__.py │ ├── misc.py │ ├── forms.py │ └── widgets.py ├── NEW.txt ├── config.ini ├── commands.py ├── util.py ├── readtag.py ├── __main__.py ├── validators.py └── const.py ├── setup.cfg ├── MANIFEST.in ├── img ├── edit.png ├── main.png └── pref.png ├── .gitignore ├── Refactor Rules ├── gen_whats_new.py ├── TODO.md ├── CONVENTIONS.md ├── LICENSE.md ├── README.md ├── CHANGELOG.md └── setup.py /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: My Docs 2 | -------------------------------------------------------------------------------- /clid/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include clid/config.ini 2 | include clid/NEW.txt 3 | -------------------------------------------------------------------------------- /clid/version.py: -------------------------------------------------------------------------------- 1 | """Version number for clid""" 2 | 3 | VERSION = '0.7.1' 4 | -------------------------------------------------------------------------------- /img/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/img/edit.png -------------------------------------------------------------------------------- /img/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/img/main.png -------------------------------------------------------------------------------- /img/pref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/img/pref.png -------------------------------------------------------------------------------- /docs/docs/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/edit.png -------------------------------------------------------------------------------- /docs/docs/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/main.png -------------------------------------------------------------------------------- /docs/docs/pref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/pref.png -------------------------------------------------------------------------------- /docs/docs/batch_tag_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/batch_tag_edit.png -------------------------------------------------------------------------------- /docs/docs/batch_tag_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/batch_tag_main.png -------------------------------------------------------------------------------- /docs/docs/main_annotated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/main_annotated.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | clid.egg-info/* 2 | docs/_site 3 | docs/.sass-cache 4 | docs/.jekyll-metadata 5 | todo.txt 6 | 7 | -------------------------------------------------------------------------------- /docs/docs/tag_preview_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudormrfbin/clid/HEAD/docs/docs/tag_preview_default.png -------------------------------------------------------------------------------- /clid/database/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Databases""" 4 | 5 | from .mp3db import Mp3DataBase 6 | from .prefdb import PreferencesDataBase 7 | -------------------------------------------------------------------------------- /Refactor Rules: -------------------------------------------------------------------------------- 1 | 2 | class docstring 3 | all attributes 4 | 5 | function docstring 6 | return value 7 | params 8 | 9 | rename attributes 10 | rename functions 11 | 12 | update docs -------------------------------------------------------------------------------- /clid/forms/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Forms used by clid for display""" 4 | 5 | from .main import MainView 6 | from .pref import PreferencesView 7 | from .editmeta import SingleEditMetaView, MultiEditMetaView 8 | -------------------------------------------------------------------------------- /clid/base/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Base classes""" 4 | 5 | from .forms import ClidMuttForm, ClidActionForm, ClidEditMetaView 6 | from .misc import ClidDataBase, ClidActionController 7 | 8 | from .widgets import ( 9 | ClidMultiLine, 10 | ClidCommandLine, 11 | ClidTextfield, ClidGenreTextfield, 12 | ClidVimTextfield, ClidVimGenreTextfiled 13 | ) 14 | -------------------------------------------------------------------------------- /gen_whats_new.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Script for generating a what's file in ./clid/""" 4 | 5 | with open('CHANGELOG.md', 'r') as log: 6 | lines = log.readlines() 7 | new = lines[lines.index('\n') + 1: lines.index('- - -\n')] 8 | 9 | temp = [] 10 | for line in new: 11 | if line.startswith('- '): 12 | temp.append('- ' + line[6:]) 13 | else: 14 | temp.append(line) 15 | 16 | with open('clid/NEW.txt', 'w') as file: 17 | file.writelines(temp) 18 | -------------------------------------------------------------------------------- /clid/NEW.txt: -------------------------------------------------------------------------------- 1 | v0.7.0 2 | ------ 3 | 4 | - Fix resize issue 5 | - Created documentation 6 | - Customizable keybindings 7 | - Autocomplete in genre tag field 8 | - Key binding for quitting app(^Q) 9 | - Tag multiple files at the same time 10 | - Invert selection made for batch tagging 11 | - Edit filename from inside the tag editor 12 | - Show correct tag preview when changing directory 13 | - Option for using regular expressions while searching 14 | - Genre can now be displayed in the status line preview 15 | - Short description of preferences option in status line 16 | - Save position of cursor in the tag editor when editing files 17 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO & BUGS 2 | =========== 3 | 4 | BUGS 5 | ---- 6 | 7 | 1. [x] Status bar doesn't change preview at top 2 tracks when `ArrowUp` or `PageUp` is pressed. 8 | 2. [x] Track number is shown as `0` in preview if track number is not set for the track. 9 | 3. [x] Status line doesn't change when switching dirs 10 | 4. [x] Resize window 11 | 5. [ ] Search is lost when renaming file 12 | 6. [x] ^Q (other keys too ?) doesn't work in pref view 13 | 7. [ ] Some keys like ^P, ^M (maybe even more) doesn't work when binded to files_view 14 | 15 | 16 | TODO 17 | ---- 18 | 19 | 1. [ ] Edit lyrics 20 | 2. [ ] Dialog for quickly changing dirs 21 | 3. [ ] Advanced search 22 | -------------------------------------------------------------------------------- /CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # Conventions In Source Code 2 | 3 | ## Naming Conventions 4 | 5 | ### Base Classes 6 | 7 | - Base classes start with `Clid`, except for `__main__.ClidApp`; Eg: `ClidMultiLine`. 8 | 9 | ### Forms 10 | 11 | - Forms end with `View`; Eg: `MainView` 12 | - Forms will have `prefdb` and `mp3db` attributes, refering to `prefdb` and `mp3db` attributes of 13 | `__main__.ClidApp`. 14 | - Traditional Forms will also have a `maindb` attribute, refering to the database it will be mostly interacting 15 | with; Eg: `maindb` attribute of `PreferencesView` will refer to `prefdb` 16 | 17 | ### Widgets 18 | 19 | - Widgets start with first name of parent form; Eg: `MainActionController` is a child widget of parent form `MainView`. 20 | - If the same widget is used by more than one form, above convention fails. 21 | 22 | ### Handlers 23 | 24 | Handlers are executed when a keypress matches a keybinding. 25 | 26 | - Handlers start with `h_`; Eg: `h_cursor_up`. 27 | - `char` parameter of a handler is generally not used. 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gokul 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 18 | THE 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 SOFTWARE. 21 | -------------------------------------------------------------------------------- /clid/config.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | # Directory in which the app will search for mp3 files recursively 3 | music_dir = ~/Music 4 | # Enable or disable Vim style keybindings 5 | vim_mode = false 6 | # Enable or disable smooth scroll 7 | smooth_scroll = true 8 | # Format in which a preview of tags of the file under cursor will be shown 9 | preview_format = %a - %l - %n. %t 10 | # Enable or disable regular expressions when searching 11 | use_regex_in_search = false 12 | # Enable or disable mouse support 13 | mouse_support = true 14 | 15 | [Keybindings] 16 | # Switch to Files View 17 | files_view = 1 18 | # Edit Preferences 19 | preferences = 2 20 | # Save tags after modifying them(Save button) 21 | save_tags = ^S 22 | # Go back to Files without saving modified tags(Cancel button) 23 | cancel_saving_tags = ^W 24 | # Select item(file) for batch tagging or similar stuff 25 | select_item = space 26 | # Invert selection made with `select_item` 27 | invert_selection = i 28 | # Refresh file list from directory 29 | reload_music_dir = u 30 | # Goto the top of the list(first item) 31 | goto_top = home 32 | # Goto the bottom of the list(last item) 33 | goto_bottom = end 34 | # Page up 35 | page_up = page_up 36 | # Page down 37 | page_down = page_down 38 | # Key to be treated as Escape(The Esc key is a bit slow) 39 | esc_key = esc 40 | # Quit the app 41 | quit = ^Q 42 | -------------------------------------------------------------------------------- /clid/commands.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from . import const 4 | 5 | 6 | class InvalidCommand(Exception): 7 | """Error raised when a command string is invalid""" 8 | pass 9 | 10 | 11 | class InvalidCommandSyntax(Exception): 12 | """Error raised when there is a syntax error in the command string, 13 | like unwanted switches, args, misspelled switches, etc 14 | """ 15 | pass 16 | 17 | 18 | def command_without_switch_and_args(command_func): 19 | """Decorator for defining commands which do not accept switches or arguments""" 20 | def decorated(self, switch, args): 21 | if switch or args: 22 | raise InvalidCommandSyntax('This command does not accept switches or arguments') 23 | else: 24 | command_func(self) 25 | return decorated 26 | 27 | 28 | class Commands(object): 29 | """Class which stores commands that are executed by the user, either through 30 | the command line or key-bindings. 31 | 32 | Attributes: 33 | app: Reference to the __main__.ClidApp 34 | """ 35 | def __init__(self, parent): 36 | self.parent = parent 37 | 38 | # TODO: do not execute command if command is not in form's scope 39 | # TODO: aliases for commands 40 | def execute_command(self, command_str): 41 | """Execute `command_str`. 42 | 43 | Args: 44 | command_str(str): A string like "mark -i" 45 | Raises: 46 | InvalidCommand: If `command_str` is not a valid one 47 | """ 48 | command, switch, args = const.COMMAND_REGEX.fullmatch(command_str).groups() 49 | try: 50 | getattr(self, command)(switch, args) 51 | except AttributeError: 52 | raise InvalidCommand('{command} does not exist'.format(command=command)) 53 | 54 | @command_without_switch_and_args 55 | def quit(self): 56 | """Quit the app""" 57 | exit() 58 | -------------------------------------------------------------------------------- /clid/forms/editmeta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Form class for editing the metadata of a track""" 4 | 5 | import os 6 | 7 | import npyscreen as npy 8 | 9 | from clid import base 10 | from clid import const 11 | from clid import readtag 12 | 13 | 14 | class SingleEditMetaView(base.ClidEditMetaView): 15 | """Edit the metadata of a *single* track.""" 16 | def create(self): 17 | file = self.parentApp.current_files[0] 18 | meta = readtag.ReadTags(file) 19 | # show name of file(can be edited) 20 | self.filenamebox = self.add( 21 | widgetClass=self._get_textbox_cls()[0], name='Filename', 22 | labelColor='STANDOUT', color='CONTROL', 23 | value=os.path.basename(file).replace('.mp3', '') 24 | ) 25 | self.nextrely += 2 26 | super().create() 27 | 28 | for tbox, field in const.TAG_FIELDS.items(): # show file's tag 29 | getattr(self, tbox).value = getattr(meta, field) 30 | 31 | def get_fields_to_save(self): 32 | return {tag: getattr(self, tbox).value for tbox, tag in const.TAG_FIELDS.items()} 33 | 34 | def do_after_saving_tags(self): 35 | """Rename the file if necessary.""" 36 | mp3 = self.files[0] 37 | new_filename = os.path.join(os.path.dirname(mp3), self.filenamebox.value) + '.mp3' 38 | if mp3 != new_filename: # filename was changed 39 | os.rename(mp3, new_filename) 40 | self.mp3db.rename_file(old=mp3, new=new_filename) 41 | self.parentApp.getForm("MAIN").load_files_to_show() 42 | 43 | 44 | class MultiEditMetaView(base.ClidEditMetaView): 45 | """Edit metadata of multiple tracks""" 46 | def create(self): 47 | # show number of files selected 48 | self.add(npy.Textfield, color='STANDOUT', editable=False, 49 | value='Editing {} files'.format(len(self.parentApp.current_files))) 50 | self.nextrely += 2 51 | super().create() 52 | 53 | def get_fields_to_save(self): 54 | # save only those fields which are not empty, to files 55 | temp = {} 56 | for tbox, tag in const.TAG_FIELDS.items(): 57 | if getattr(self, tbox).value != '': 58 | temp[tag] = getattr(self, tbox).value 59 | return temp 60 | -------------------------------------------------------------------------------- /clid/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Common utilities for clid""" 4 | 5 | from . import const 6 | 7 | 8 | def get_color(name): 9 | """Return the color name that is to be used for `name`. 10 | `name` is 'Error', 'Info', etc 11 | """ 12 | colors = { 13 | 'Info': 'CAUTION', 14 | 'Error': 'WARNING' 15 | } 16 | return colors[name] 17 | 18 | def resolve_genre(num_gen): 19 | """Convert numerical genre values to readable values. Genre may be 20 | saved as a str of the format '(int)' by applications like EasyTag. 21 | Args: 22 | num_gen (str): str representing the genre. 23 | Returns: 24 | str: Name of the genre (Electronic, Blues, etc). Returns 25 | num_gen itself if it doesn't match the format. 26 | """ 27 | match = const.GENRE_PAT.findall(num_gen) 28 | if match: 29 | try: 30 | return const.GENRES[int(match[0])] # retrun the string form of genre 31 | except IndexError: 32 | # num_gen is in the form of a num gen, but the number is invalid 33 | return '' 34 | else: 35 | # it's probably a normal string 36 | return num_gen 37 | 38 | 39 | def is_date_in_valid_format(date): 40 | """See if date string is in a format acceptable by stagger. 41 | Returns: 42 | bool: True if date is in valid format, False otherwise 43 | """ 44 | match = const.DATE_PATTERN.match(date) 45 | if match is None or match.end() != len(date): 46 | return False 47 | return True 48 | 49 | 50 | def is_track_number_valid(track): 51 | """Check if track number is a valid one. `track` must be '' or 52 | a number string. 53 | """ 54 | return track.isnumeric() or track == '' 55 | 56 | 57 | def run_if_window_not_empty(update_status_line): 58 | """Decorator which accepts a handler as param and executes it 59 | only if the window is not empty(if there is anything to display). 60 | Args: 61 | update_status_line(bool): Whether to update the status line 62 | """ 63 | def decorated(handler): 64 | def handler_wrapper(self, char): 65 | if self.values: 66 | handler(self, char) 67 | if update_status_line: 68 | self.set_current_status() 69 | return handler_wrapper 70 | return decorated 71 | -------------------------------------------------------------------------------- /clid/readtag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Modified version of stagger to be used by clid""" 4 | 5 | import stagger 6 | 7 | from . import util 8 | 9 | 10 | def getter_and_setter_for_tag(tag_field): 11 | """Used to construct appropriate getters and setters for attributes like 12 | artist, album, etc in `ReadTags` class. 13 | Args: 14 | tag_field(str): tag field for which getters and setters are 15 | made. It will later be an attribute of the class. 16 | Returns: 17 | tuple: tuple of (getter, setter) 18 | """ 19 | def getter(self): 20 | return getattr(self.meta, tag_field) 21 | 22 | def setter(self, value): 23 | setattr(self.meta, tag_field, value) 24 | 25 | return (getter, setter) 26 | 27 | 28 | class ReadTags(): 29 | """Read tags from a file. This is a wrapper around stagger's 30 | default behaviour to make it easy to write code. 31 | """ 32 | def __init__(self, filename): 33 | try: 34 | self.meta = stagger.read_tag(filename) 35 | except stagger.NoTagError: 36 | self.meta = stagger.Tag23() # create an ID3v2.3 instance 37 | self.write = self.meta.write # for saving to file 38 | 39 | date = property(*getter_and_setter_for_tag('date')) 40 | album = property(*getter_and_setter_for_tag('album')) 41 | title = property(*getter_and_setter_for_tag('title')) 42 | artist = property(*getter_and_setter_for_tag('artist')) 43 | comment = property(*getter_and_setter_for_tag('comment')) 44 | album_artist = property(*getter_and_setter_for_tag('album_artist')) 45 | 46 | @property 47 | def genre(self): 48 | """Genre tag; modified so that correct(readable) genre is returned 49 | instead of numerical gene 50 | """ 51 | return util.resolve_genre(self.meta.genre) 52 | 53 | @genre.setter 54 | def genre(self, value): 55 | self.meta.genre = value 56 | 57 | @property 58 | def track(self): 59 | """Track number. Modified so that a string is returned. If 60 | track number is not set (self.meta.track will be 0), '' 61 | is returned 62 | """ 63 | return '' if self.meta.track == 0 else str(self.meta.track) 64 | 65 | @track.setter 66 | def track(self, value): 67 | """We want the track number to be deleted, if value is ''. For this, 68 | self.meta.track has to be '0'. 69 | """ 70 | value = '0' if value == '' else value 71 | self.meta.track = value 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clid 2 | 3 | > Note: Clid is being rewritten at the moment, in the [rewrite](https://github.com/gokulsoumya/clid/tree/rewrite) 4 | > branch. Don't let that stop you from opening issues, though :) 5 | 6 | Clid is a command line app for editing tags of mp3 files. Clid is different from other 7 | command line tools to edit tags, as you can edit tags in a curses based ui. 8 | 9 | ![clid main window](docs/docs/main.png "Main Window") 10 | 11 | ## Installation 12 | 13 | See [wiki](docs/docs/index.md) for detailed installation instructions. 14 | 15 | ### Using pip 16 | 17 | ```shell 18 | $ [sudo] pip3 install clid 19 | ``` 20 | 21 | ### From Source 22 | 23 | ```shell 24 | $ git clone https://github.com/GokulSoumya/clid.git 25 | $ cd clid 26 | $ [sudo] python3 setup.py install 27 | ``` 28 | 29 | ### Updating 30 | 31 | To update the app, run 32 | 33 | ```shell 34 | $ [sudo] pip install -U clid 35 | ``` 36 | 37 | You can launch the app by entering `clid` in the command line. 38 | 39 | ## Usage 40 | 41 | ### Quick Start 42 | 43 | 1. Move with arrow keys or `j` and `k`. 44 | 2. Enter to select a file. 45 | 3. Edit the tags. 46 | 4. `OK` to save the tags or `Cancel` to abort edit. 47 | 5. Type `:q` at main window to quit. 48 | 49 | See the [wiki](docs/docs/index.md) for documentation and additional details. 50 | 51 | ## Changelog 52 | 53 | ### v0.7.0 54 | 55 | - Fix resize issue 56 | - Created documentation 57 | - Customizable keybindings 58 | - Refactor the whole codebase 59 | - Autocomplete in genre tag field 60 | - Key binding for quitting app(^Q) 61 | - Tag multiple files at the same time 62 | - Invert selection made for batch tagging 63 | - Edit filename from inside the tag editor 64 | - Show correct tag preview when changing directory 65 | - Option for using regular expressions while searching 66 | - Genre can now be displayed in the status line preview 67 | - Short description of preferences option in status line 68 | - Save position of cursor in the tag editorwhen editing files 69 | 70 | ### v0.6.3 71 | 72 | - Vi keybindings 73 | - Added option for smooth scroll 74 | - Preferences are now saved when updating the app 75 | - Validators for `smooth_scroll` and `preview_format` 76 | - Display a "What's New" Popup when app is run after an update 77 | 78 | ## Thanks 79 | 80 | I couldn't have made this app without these amazing libraries: 81 | 82 | - [npyscreen](https://bitbucket.org/npcole/npyscreen), a Python wrapper around ncurses. 83 | - [stagger](https://github.com/lorentey/stagger), an ID3v1/ID3v2 tag manipulation package written in pure Python 3 84 | - [configobj](https://github.com/DiffSK/configobj), a Python 3+ compatible port of the configobj library 85 | -------------------------------------------------------------------------------- /clid/base/misc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Base classes for miscellaneous objects""" 4 | 5 | import re 6 | 7 | import npyscreen as npy 8 | 9 | 10 | class ClidActionController(npy.ActionControllerSimple): 11 | """Base class for the command line at the bottom of the screen""" 12 | 13 | def create(self): 14 | self.add_action('^:q(uit)?$', lambda *args, **kwargs: exit(), live=False) 15 | self.add_action('^:bind .+', function=self.change_key, live=False) 16 | self.add_action('^:set .+', function=self.change_setting, live=False) 17 | 18 | def change_setting(self, command_line, widget_proxy, live): 19 | """Change a setting in the ini file. 20 | command_line will be of the form `:set option=value` 21 | """ 22 | option, value = command_line[5:].split(sep='=') 23 | self.parent.prefdb.set_pref(option, value) 24 | # reload and display settings 25 | self.parent.parentApp.getForm("SETTINGS").load_pref() 26 | 27 | def change_key(self, command_line, widget_proxy, live): 28 | """Change a keybinding. 29 | command_line will be of the form `:bind action=key` 30 | """ 31 | option, value = command_line[6:].split(sep='=') 32 | self.parent.prefdb.set_key(option, value) 33 | # reload and display settings 34 | self.parent.parentApp.getForm("SETTINGS").load_pref() 35 | 36 | 37 | class ClidDataBase(): 38 | """General structure of databases used by clid""" 39 | 40 | def __init__(self, app): 41 | self.app = app 42 | 43 | def get_values_to_display(self): 44 | """Return a list of strings that will be displayed on the screen""" 45 | pass 46 | 47 | def get_filtered_values(self, search): 48 | """Search the list of items returned by `get_values_to_display` for the 49 | substring `search` 50 | """ 51 | if search == '': 52 | return self.get_values_to_display() 53 | search = search.lower() 54 | if self.app.prefdb.is_option_enabled('use_regex_in_search'): 55 | try: 56 | # check for invalid regex 57 | re.compile(search) 58 | return [item for item in self.get_values_to_display() if re.search(search, item)] 59 | except re.error: 60 | return self.get_values_to_display() 61 | return [item for item in self.get_values_to_display() if search in item.lower()] 62 | 63 | def parse_info_for_status(self, str_needing_info, *args, **kwargs): 64 | """Return a string that will be displayed on the status line, providing 65 | additional info on the item under the cursor 66 | """ 67 | pass 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | v0.7.1 5 | ------ 6 | 7 | - [x] Restructure files 8 | - [x] Enable or disable mouse support 9 | - [x] Colored error and info messages in command line 10 | - [x] Fix cursor going to end when command line is edited 11 | 12 | - - - 13 | 14 | v0.7.0 15 | ------ 16 | 17 | - [x] Create docs 18 | - [x] Batch tag files 19 | - [x] Invert selection 20 | - [x] Option for regex search 21 | - [x] Customizable keybindings 22 | - [x] Fix resize(min_l and min_c) 23 | - [x] Refactor the whole codebase 24 | - [x] Autocomplete in genre tag field 25 | - [x] Key binding for quitting app(^Q) 26 | - [x] Added genre to format specifiers 27 | - [x] Change color of filename textbox 28 | - [x] Filename field in tag editing view 29 | - [x] Short description of preferences option in status line 30 | - [x] Improved speed when batch tagging large number of files 31 | - [x] Save position of cursor(in which tbox) when editing files 32 | - [x] Show correct preview when changing dirs; 'No Files Found In Directory' if no mp3 33 | 34 | - - - 35 | 36 | v0.6.3.1 37 | -------- 38 | 39 | - [x] Fix: Issue #2 in Github - error when installing v0.6.3 40 | 41 | v0.6.3 42 | ------ 43 | 44 | - [x] Vi keybindings 45 | - [x] Added option for smooth scroll 46 | - [x] Preferences are now saved when updating the app 47 | - [x] Validators for smooth_scroll and preview_format 48 | - [x] Display a "What's New" Popup when app is run after an update 49 | 50 | - - - 51 | 52 | v0.6.2 53 | ------ 54 | 55 | - [x] Fix: Issue #1 in Github 56 | - [x] Added key-binding(`u`) for reloading `music_dir` 57 | - [x] Fix: All option are now aligned properly in preferences view 58 | - [x] Added validators for preferences(Error message is shown if an error occurs) 59 | 60 | v0.6.1 61 | ------ 62 | 63 | - [x] Add track number to preview format option in preferences 64 | 65 | v0.6.0 66 | ------ 67 | 68 | - [x] Add Home/End for command line 69 | - [x] Use `set` command(to edit preferences) form main view 70 | - [x] Add preferences option for custom preview in main view 71 | 72 | 73 | v0.5.3 74 | ------ 75 | 76 | - [x] Add HOME and END keys to text boxes 77 | - [x] Fix: Error thrown if date is not of the format YYYY-MM-DD 78 | 79 | 80 | v0.5.2 81 | ------ 82 | 83 | - [x] Add date tag to editor 84 | - [x] Add label to Preferences 85 | - [x] Add comment tag to editor 86 | - [x] Fix: Genre not being saved 87 | - [x] Fix: Error thrown when Track No. is str(has to be int) 88 | - [x] Fix: Error thrown when `OK` or `Cancel` is pressed 89 | 90 | 91 | v0.5.1 92 | ------ 93 | 94 | - [x] Add keybindings to save(^S) and cancel(^Q) when editing tags 95 | - [x] Update status line when page up/down is pressed in the main view 96 | - [x] Fix: app crashes when up/down, page/up/down keys are pressed if there are no files in display 97 | -------------------------------------------------------------------------------- /clid/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Clid is an app to edit the id3v2 tags of mp3 files from the command line.""" 4 | 5 | import curses 6 | 7 | import configobj 8 | import npyscreen 9 | 10 | from . import const 11 | from . import forms 12 | from . import database 13 | 14 | 15 | class ClidApp(npyscreen.NPSAppManaged): 16 | """Class used by npyscreen to manage forms. 17 | 18 | Attributes: 19 | current_files(list): 20 | List of abs path of files selected for editing 21 | settings(configobj.ConfigObj): 22 | Object used to read and write preferences 23 | mp3db(database.Mp3DataBase): 24 | Used to manage mp3 files. Handles discovering files, storing a 25 | metadata cache, etc 26 | prefdb(database.PreferencesDataBase): 27 | Used to manage preferences. Handles validating new settings, etc 28 | current_field(int): 29 | Used to automatically jump to last selected tag field when 30 | editing tags 31 | """ 32 | def __init__(self, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | self.current_files = [] # changed when a file is selected in main screen 35 | self.current_field = 0 # remember last edited tag field 36 | self.settings = configobj.ConfigObj(const.CONFIG_DIR + 'clid.ini') 37 | # databases for managing mp3 files and preferences 38 | self.prefdb = database.PreferencesDataBase(app=self) 39 | self.mp3db = database.Mp3DataBase(app=self) 40 | 41 | def set_current_files(self, files): 42 | """Set `current_files` attribute""" 43 | self.current_files = [self.mp3db.get_abs_path(file) for file in files] 44 | 45 | def show_notif(self, title, msg): 46 | """Notify the user of something, either using the command line or a popup""" 47 | self._THISFORM.show_notif(title, msg) 48 | 49 | def onStart(self): 50 | self.configure_mouse_support() 51 | 52 | npyscreen.setTheme(npyscreen.Themes.ElegantTheme) 53 | self.addForm("MAIN", forms.MainView) 54 | self.addForm("SETTINGS", forms.PreferencesView) 55 | # addFormClass to create a new instance every time 56 | self.addFormClass("MULTIEDIT", forms.MultiEditMetaView) 57 | self.addFormClass("SINGLEEDIT", forms.SingleEditMetaView) 58 | 59 | def configure_mouse_support(self): 60 | """Configure mouse to be enabled or disabled""" 61 | if self.prefdb.is_option_enabled('mouse_support') is True: 62 | curses.mousemask(curses.ALL_MOUSE_EVENTS) 63 | else: 64 | curses.mousemask(0) # do not listen for mouse events 65 | 66 | 67 | def run(): 68 | """Launch the app. This function is also used as an entry point.""" 69 | ClidApp().run() 70 | 71 | 72 | if __name__ == '__main__': 73 | run() 74 | -------------------------------------------------------------------------------- /clid/validators.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Contains validating functions used to check whether valid values 4 | are given when changing preferencs. ValidationError is raised 5 | if value to be tested doesn't pass the test. 6 | """ 7 | 8 | import os 9 | 10 | from . import const 11 | 12 | 13 | class ValidationError(Exception): 14 | """Raised when validation fails""" 15 | pass 16 | 17 | 18 | def true_or_false(test): 19 | """Checks whether test is either 'true' or 'false' 20 | Used by other functions. 21 | """ 22 | if not(test == 'true' or test == 'false'): 23 | raise ValidationError( 24 | 'Acceptable values are "true" or "false"; "{}" is not valid'.format(test) 25 | ) 26 | 27 | 28 | def music_dir(test): 29 | """Checks whether `test` exists and is a directory. 30 | Args: 31 | test(str): path to be tested. 32 | Raises: 33 | ValidationError: if `test` doesn't exist or is not directory 34 | """ 35 | if not os.path.exists(test): 36 | raise ValidationError('"{}" doesn\'t exist'.format(test)) 37 | if not os.path.isdir(test): 38 | raise ValidationError('"{}" is not a directory'.format(test)) 39 | 40 | 41 | def preview_format(test): 42 | """Checks whether is a valid which can be used as a preview format 43 | Args: 44 | test(str): str to be tested 45 | Raises: 46 | ValidationError 47 | """ 48 | valid_specs_list = const.FORMAT_SPECS.keys() 49 | specs_list = const.FORMAT_PAT.findall(test) 50 | 51 | for spec in specs_list: 52 | if spec not in valid_specs_list: 53 | raise ValidationError('"{}" is not a valid format specifier'.format(spec)) 54 | 55 | 56 | VALIDATORS = { 57 | 'music_dir': music_dir, 58 | 'vim_mode': true_or_false, 59 | 'smooth_scroll': true_or_false, 60 | 'preview_format': preview_format, 61 | 'use_regex_in_search': true_or_false, 62 | 'mouse_support': true_or_false 63 | } 64 | 65 | 66 | def validate(option, test): 67 | """Run the validation function for `option` with value `test` 68 | Args: 69 | option(str): Option against which `test` will be validated 70 | test(str): Value to be tested 71 | Raises: 72 | ValidationError: If `test` is an invalid value for the setting `option` 73 | """ 74 | VALIDATORS[option](test) 75 | 76 | 77 | def validate_key(key, already_used_keys): 78 | """Check whether `key` can be used as a keybinding 79 | Args: 80 | already_set_keys(sequence): 81 | Keys that cannot be set because they are bound to other actions 82 | """ 83 | if key in already_used_keys: 84 | raise ValidationError('"{}" is already bound to another action'.format(key)) 85 | if not (const.VALID_KEY_CHARS.fullmatch(key) or key in const.VALID_KEYS_EXTRA): 86 | raise ValidationError('"{}" is an invalid keybinding'.format(key)) 87 | -------------------------------------------------------------------------------- /clid/forms/pref.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Window for editing preferences""" 4 | 5 | from clid import base 6 | 7 | 8 | class PrefMultiline(base.ClidMultiLine): 9 | def __init__(self, *args, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | self.high_lines = self.get_lines_to_be_highlighted( 12 | self.parent.prefdb.get_section_names() 13 | ) # list of strings to be highlighted when displaying prefs - section names 14 | 15 | @staticmethod 16 | def get_lines_to_be_highlighted(sections): 17 | """Return a list of string that have to be highlighted in the 18 | pref window(section names) 19 | """ 20 | return (sections + [len(sec) * '-' for sec in sections] + [' ']) 21 | 22 | def h_select(self, char): 23 | if self.get_selected() in self.high_lines: 24 | return None 25 | # find section to which current pref belongs 26 | l = self.values[:self.cursor_line] 27 | l.reverse() 28 | section = l[l.index(' ') - 1] 29 | opt, val = self.get_selected().split(maxsplit=1) 30 | 31 | if section == 'General': 32 | self.parent.wCommand.set_value(':set {opt}={val}'.format(opt=opt, val=val)) 33 | elif section == 'Keybindings': 34 | self.parent.wCommand.set_value(':bind {opt}={val}'.format(opt=opt, val=val)) 35 | 36 | def set_current_status(self, *args, **kwargs): 37 | if self.get_selected() in self.high_lines: 38 | self.parent.wStatus2.value = '' # cursor under section name or blank line 39 | self.parent.display() 40 | else: 41 | super().set_current_status() 42 | 43 | def _set_line_highlighting(self, line, value_indexer): 44 | """Highlight sections""" 45 | try: 46 | if self.values[value_indexer] in self.high_lines: 47 | self.set_is_line_important(line, True) 48 | except IndexError: 49 | # value of value_indexer may be upto the current height of the window 50 | # so if all prefs fit in the screen, value_indexer may be > len(self.values) 51 | pass 52 | self.set_is_line_cursor(line, False) 53 | 54 | 55 | class PreferencesView(base.ClidMuttForm): 56 | """View for editing preferences/settings""" 57 | MAIN_WIDGET_CLASS = PrefMultiline 58 | ACTION_CONTROLLER = base.ClidActionController 59 | COMMAND_WIDGET_CLASS = base.ClidCommandLine 60 | 61 | def __init__(self, parentApp, *args, **kwargs): 62 | super().__init__(parentApp, *args, **kwargs) 63 | 64 | self.load_keys() 65 | self.load_pref() 66 | 67 | self.wStatus1.value = 'Preferences ' 68 | self.wMain.set_current_status() 69 | 70 | @property 71 | def maindb(self): 72 | return self.prefdb 73 | 74 | def load_pref(self): 75 | """[Re]load preferences after being changed""" 76 | self.wMain.values = self.prefdb.get_values_to_display() 77 | self.display() 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | from setuptools import setup 6 | from setuptools.command.install import install 7 | 8 | from clid import version 9 | 10 | if sys.version_info[0] != 3: 11 | sys.exit('clid requires Python3') 12 | 13 | HOME = os.path.expanduser('~') 14 | HERE = os.path.dirname(os.path.abspath(__file__)) 15 | CONFIG_DIR = os.path.join(HOME, '.config/clid') 16 | USER_CONFIG_FILE = os.path.join(CONFIG_DIR, 'clid.ini') 17 | 18 | LONG_DES = """ 19 | Clid is a command line app written in Python3 to manage your mp3 files' ID3 tags. 20 | Unlike other tools, clid provides a graphical interface in the terminal to edit 21 | tags, much like the way `cmus `_ does for playing 22 | mp3 files in the terminal. 23 | 24 | See the `HOMEpage `_ for more details. 25 | """ 26 | 27 | 28 | def set_up_pref_file(): 29 | """Update or create a config file""" 30 | import configobj 31 | try: 32 | os.makedirs(CONFIG_DIR) 33 | except FileExistsError: 34 | pass 35 | 36 | # get the ini file with default settings 37 | default_config = configobj.ConfigObj(os.path.join(HERE, 'clid/config.ini')) 38 | try: 39 | # get user's config file if app is already installed 40 | user_config = configobj.ConfigObj(USER_CONFIG_FILE, file_error=True) 41 | except OSError: 42 | # expand `~/Music` if app is being installed for the first time 43 | user_config = configobj.ConfigObj(USER_CONFIG_FILE) 44 | default_config['General']['music_dir'] = os.path.join(HOME, 'Music', '') 45 | 46 | default_config.merge(user_config) 47 | default_config.write(outfile=open(USER_CONFIG_FILE, 'wb')) 48 | 49 | 50 | def make_whats_new(): 51 | with open(os.path.join(HERE, 'clid/NEW.txt'), 'r') as file: 52 | to_write = file.read() 53 | with open(os.path.join(CONFIG_DIR, 'NEW'), 'w') as file: 54 | file.write(to_write) 55 | 56 | 57 | class PostInstall(install): 58 | def run(self): 59 | set_up_pref_file() 60 | make_whats_new() 61 | with open(os.path.join(CONFIG_DIR, 'first'), 'w') as file: 62 | # used to display What's New popup(if true) 63 | file.write('true') 64 | 65 | install.run(self) 66 | 67 | 68 | setup( 69 | name='clid', 70 | version=version.VERSION, 71 | license='MIT', 72 | 73 | packages=['clid', 'clid.base', 'clid.database', 'clid.forms'], 74 | 75 | description='Command line app based on ncurses to edit ID3 tags of mp3 files', 76 | long_description=LONG_DES, 77 | 78 | keywords='mp3 id3 command-line ncurses', 79 | classifiers=[ 80 | 'Topic :: Multimedia :: Sound/Audio', 81 | 'Topic :: Multimedia :: Sound/Audio :: Editors', 82 | 'Topic :: Multimedia :: Sound/Audio :: Players :: MP3', 83 | 'License :: OSI Approved :: MIT License', 84 | 'Environment :: Console', 85 | 'Environment :: Console :: Curses', 86 | 'Intended Audience :: End Users/Desktop', 87 | 'Development Status :: 3 - Alpha', 88 | 'Programming Language :: Python :: 3 :: Only', 89 | ], 90 | 91 | author='Gokul', 92 | author_email='gokulps15@gmail.com', 93 | 94 | url='https://github.com/GokulSoumya/clid', 95 | 96 | # See https://PyPI.python.org/PyPI?%3Aaction=list_classifiers 97 | # See http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files 98 | # See cheat in github 99 | 100 | install_requires=['npyscreen', 'stagger', 'configobj'], 101 | 102 | cmdclass={ 103 | 'install': PostInstall 104 | }, 105 | entry_points={ 106 | 'console_scripts': [ 107 | 'clid = clid.__main__:run' 108 | ] 109 | } 110 | ) 111 | -------------------------------------------------------------------------------- /clid/database/mp3db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Database for managing music files""" 4 | 5 | import os 6 | import glob 7 | 8 | from clid import base 9 | from clid import const 10 | from clid import readtag 11 | 12 | 13 | class Mp3DataBase(base.ClidDataBase): 14 | """Class to manage mp3 files. 15 | Attributes: 16 | app(npyscreen.NPSAppManaged): Reference to parent application 17 | preview_format(str): 18 | String with format specifiers used to display preview of 19 | files' tags; Eg: '%a - %l - %t' 20 | format_specs(list): 21 | List of format specifiers in preview_format; Eg:['%l', '%a'] 22 | meta_cache(dict): 23 | Cache which holds the metadata of files as they are selected. 24 | Basename of file as key and metadata as value. 25 | mp3_basenames(list): 26 | Holds basename of mp3 files in alphabetical order. 27 | file_dict(dict): 28 | Basename as key and abs path as value, of mp3 files. 29 | """ 30 | 31 | def __init__(self, app): 32 | super().__init__(app) 33 | self.load_mp3_files_from_music_dir() 34 | self.load_preview_format() 35 | 36 | def load_preview_format(self): 37 | """[Re]load the preview format and. Used when `preview_format` option 38 | is changed by user. 39 | Attributes Changed: 40 | meta_cache, preview_format, format_specs 41 | """ 42 | self.meta_cache = dict() # empty the meta_cache as preview format has changed 43 | self.preview_format = self.app.prefdb.get_pref('preview_format') 44 | self.format_specs = const.FORMAT_PAT.findall(self.preview_format) 45 | 46 | def load_mp3_files_from_music_dir(self): 47 | """Re[load] the list of mp3 files in case `music_dir` is changed 48 | Attributes Changed: 49 | file_dict, mp3_basenames 50 | """ 51 | mp3_files = [] 52 | mp3_dir = self.app.prefdb.get_pref('music_dir') 53 | # get all mp3 files in the dir and sub-dirs 54 | for dir_tree in os.walk(mp3_dir, followlinks=True): 55 | mp3_found = glob.glob(os.path.join(dir_tree[0], '*.mp3')) 56 | mp3_files.extend(mp3_found) 57 | 58 | # make a dict with the basename as key and absolute path as value 59 | self.file_dict = {os.path.basename(mp3): mp3 for mp3 in mp3_files} 60 | # alphabetically ordered tuple of filenames 61 | self.mp3_basenames = tuple(sorted(self.file_dict.keys())) 62 | 63 | def get_values_to_display(self): 64 | """Return values that is to be displayed in the corresponding form""" 65 | return self.mp3_basenames 66 | 67 | def get_abs_path(self, path): 68 | """Return the absolute path of path from self.file_dict""" 69 | return self.file_dict[path] 70 | 71 | def parse_info_for_status(self, str_needing_info, force=False): 72 | """Make a string that will be displayed in the status line of corresponding 73 | form, based on the user's `preview_format` option, 74 | (Eg: `artist - album - track_name`) and then add it to meta_cache. 75 | Args: 76 | filename: the filename(basename of file) 77 | force: reconstruct the string even if it is already in meta_cache and 78 | add it to meta_cache 79 | Returns: 80 | str: String constructed 81 | Note: 82 | `str_needing_info` will be a basename of a file 83 | """ 84 | filename = str_needing_info 85 | # make a copy of format and replace specifiers with tags 86 | p_format = self.preview_format 87 | if (filename not in self.meta_cache) or force: 88 | meta = readtag.ReadTags(self.get_abs_path(filename)) 89 | for spec in self.format_specs: 90 | tag = const.FORMAT_SPECS[spec] # get corresponding tag name 91 | p_format = p_format.replace(spec, getattr(meta, tag)) 92 | self.meta_cache[filename] = p_format 93 | 94 | return self.meta_cache[filename] 95 | 96 | def rename_file(self, old, new): 97 | """Rename a file. This method replaces all references of `old` with new 98 | Used externally when a file is renamed. 99 | Args: 100 | old(str): abs path to old name of file 101 | new(str): abs path to new name of file 102 | """ 103 | del self.file_dict[os.path.basename(old)] 104 | self.file_dict[os.path.basename(new)] = new 105 | # reconstruct to include new file 106 | self.mp3_basenames = tuple(sorted(self.file_dict.keys())) 107 | 108 | del self.meta_cache[os.path.basename(old)] 109 | self.parse_info_for_status(os.path.basename(new)) # replace in meta_cache 110 | -------------------------------------------------------------------------------- /clid/const.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Constants used by clid""" 4 | 5 | import os 6 | import re 7 | import curses 8 | 9 | # default config directory where data files are kept 10 | CONFIG_DIR = os.path.expanduser('~/.config/clid/') 11 | 12 | # Tuple having genres as items and numerical value used by id3v2 as index 13 | GENRES = ( 14 | "Blues", 15 | "Classic Rock", 16 | "Country", 17 | "Dance", 18 | "Disco", 19 | "Funk", 20 | "Grunge", 21 | "Hip-Hop", 22 | "Jazz", 23 | "Metal", 24 | "New Age", 25 | "Oldies", 26 | "Other", 27 | "Pop", 28 | "R&B", 29 | "Rap", 30 | "Reggae", 31 | "Rock", 32 | "Techno", 33 | "Industrial", 34 | "Alternative", 35 | "Ska", 36 | "Death Metal", 37 | "Pranks", 38 | "Soundtrack", 39 | "Euro-Techno", 40 | "Ambient", 41 | "Trip-Hop", 42 | "Vocal", 43 | "Jazz+Funk", 44 | "Fusion", 45 | "Trance", 46 | "Classical", 47 | "Instrumental", 48 | "Acid", 49 | "House", 50 | "Game", 51 | "Sound Clip", 52 | "Gospel", 53 | "Noise", 54 | "Alt. Rock", 55 | "Bass", 56 | "Soul", 57 | "Punk", 58 | "Space", 59 | "Meditative", 60 | "Instrumental Pop", 61 | "Instrumental Rock", 62 | "Ethnic", 63 | "Gothic", 64 | "Darkwave", 65 | "Techno-Industrial", 66 | "Electronic", 67 | "Pop-Folk", 68 | "Eurodance", 69 | "Dream", 70 | "Southern Rock", 71 | "Comedy", 72 | "Cult", 73 | "Gangsta Rap", 74 | "Top 40", 75 | "Christian Rap", 76 | "Pop/Funk", 77 | "Jungle", 78 | "Native American", 79 | "Cabaret", 80 | "New Wave", 81 | "Psychedelic", 82 | "Rave", 83 | "Showtunes", 84 | "Trailer", 85 | "Lo-Fi", 86 | "Tribal", 87 | "Acid Punk", 88 | "Acid Jazz", 89 | "Polka", 90 | "Retro", 91 | "Musical", 92 | "Rock & Roll", 93 | "Hard Rock", 94 | "Folk", 95 | "Folk-Rock", 96 | "National Folk", 97 | "Swing", 98 | "Fast-Fusion", 99 | "Bebop", 100 | "Latin", 101 | "Revival", 102 | "Celtic", 103 | "Bluegrass", 104 | "Avantgarde", 105 | "Gothic Rock", 106 | "Progressive Rock", 107 | "Psychedelic Rock", 108 | "Symphonic Rock", 109 | "Slow Rock", 110 | "Big Band", 111 | "Chorus", 112 | "Easy Listening", 113 | "Acoustic", 114 | "Humour", 115 | "Speech", 116 | "Chanson", 117 | "Opera", 118 | "Chamber Music", 119 | "Sonata", 120 | "Symphony", 121 | "Booty Bass", 122 | "Primus", 123 | "Porn Groove", 124 | "Satire", 125 | "Slow Jam", 126 | "Club", 127 | "Tango", 128 | "Samba", 129 | "Folklore", 130 | "Ballad", 131 | "Power Ballad", 132 | "Rhythmic Soul", 133 | "Freestyle", 134 | "Duet", 135 | "Punk Rock", 136 | "Drum Solo", 137 | "A Cappella", 138 | "Euro-House", 139 | "Dance Hall", 140 | "Goa", 141 | "Drum & Bass", 142 | "Club-House", 143 | "Hardcore", 144 | "Terror", 145 | "Indie", 146 | "BritPop", 147 | "Afro-Punk", 148 | "Polsk Punk", 149 | "Beat", 150 | "Christian Gangsta Rap", 151 | "Heavy Metal", 152 | "Black Metal", 153 | "Crossover", 154 | "Contemporary Christian", 155 | "Christian Rock", 156 | "Merengue", 157 | "Salsa", 158 | "Thrash Metal", 159 | "Anime", 160 | "JPop", 161 | "Synthpop", 162 | "Abstract", 163 | "Art Rock", 164 | "Baroque", 165 | "Bhangra", 166 | "Big Beat", 167 | "Breakbeat", 168 | "Chillout", 169 | "Downtempo", 170 | "Dub", 171 | "EBM", 172 | "Eclectic", 173 | "Electro", 174 | "Electroclash", 175 | "Emo", 176 | "Experimental", 177 | "Garage", 178 | "Global", 179 | "IDM", 180 | "Illbient", 181 | "Industro-Goth", 182 | "Jam Band", 183 | "Krautrock", 184 | "Leftfield", 185 | "Lounge", 186 | "Math Rock", 187 | "New Romantic", 188 | "Nu-Breakz", 189 | "Post-Punk", 190 | "Post-Rock", 191 | "Psytrance", 192 | "Shoegaze", 193 | "Space Rock", 194 | "Trop Rock", 195 | "World Music", 196 | "Neoclassical", 197 | "Audiobook", 198 | "Audio Theatre", 199 | "Neue Deutsche Welle", 200 | "Podcast", 201 | "Indie Rock", 202 | "G-Funk", 203 | "Dubstep", 204 | "Garage Rock", 205 | "Psybient", 206 | ) 207 | 208 | GENRE_PAT = re.compile(r'\(([0-9]+)\)') 209 | 210 | # dict containing format specifiers to be used to display preview 211 | FORMAT_SPECS = { 212 | '%y': 'date', 213 | '%l': 'album', 214 | '%t': 'title', 215 | '%n': 'track', 216 | '%g': 'genre', 217 | '%a': 'artist', 218 | '%c': 'comment', 219 | '%A': 'album_artist' 220 | } 221 | 222 | # for matching format specifiers 223 | FORMAT_PAT = re.compile(r'%.') 224 | 225 | # dict with {name of textbox: name of field like artist. album, etc} 226 | TAG_FIELDS = { 227 | 'dat': 'date', 228 | 'tit': 'title', 229 | 'alb': 'album', 230 | 'gen': 'genre', 231 | 'tno': 'track', 232 | 'art': 'artist', 233 | 'com': 'comment', 234 | 'ala': 'album_artist' 235 | } 236 | 237 | # value of date must match this regex 238 | DATE_PATTERN = re.compile(r"""(?x)\s* 239 | ((?P[0-9]{4}) # YYYY 240 | (-(?P[01][0-9]) # -MM 241 | (-(?P[0-3][0-9]) # -DD 242 | )?)?)? 243 | [ T]? 244 | ((?P[0-2][0-9]) # HH 245 | (:(?P[0-6][0-9]) # :MM 246 | (:(?P[0-6][0-9]) # :SS 247 | )?)?)?\s* 248 | """) 249 | 250 | # name of valid keys other than single chars 251 | VALID_KEYS_EXTRA = { 252 | 'esc': curses.ascii.ESC, 253 | 'tab': curses.ascii.TAB, 254 | 'end': curses.KEY_END, 255 | 'home': curses.KEY_HOME, 256 | 'space': curses.ascii.SP, 257 | 'insert': curses.KEY_IC, 258 | 'delete': curses.KEY_DC, 259 | 'page_up': curses.KEY_PPAGE, 260 | 'page_down': curses.KEY_NPAGE, 261 | } 262 | 263 | # regex for valid keybindings of the form `^A`, `a`, `A` 264 | VALID_KEY_CHARS = re.compile(r'\^[A-Z]|.') 265 | 266 | # regex used to extract command, switches and arguments from a string 267 | COMMAND_REGEX = re.compile(r'(?P\S+)(?: -(?P\w))?(?: (?P.+))?') 268 | -------------------------------------------------------------------------------- /clid/database/prefdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Database for managing preferences""" 4 | 5 | import configobj 6 | 7 | from clid import base 8 | from clid import const 9 | from clid import validators 10 | 11 | 12 | def change_pref(section): 13 | """Decorator for changing preferences. 14 | Args: 15 | section(str): Section to which a preference will belong to(Eg; General) 16 | """ 17 | def decorated(func): 18 | def wrapper_func(self, option, value): 19 | if option in self._pref[section]: 20 | try: 21 | func(self, option, value) 22 | self._pref.write() # save to file 23 | except validators.ValidationError as err: 24 | # invalid value for specified option 25 | self.app.show_notif(msg=str(err), title='Error') 26 | else: 27 | # invalid option(not in preferences) 28 | self.app.show_notif( 29 | msg='"{}" is an invalid option'.format(option), title='Error' 30 | ) 31 | return wrapper_func 32 | return decorated 33 | 34 | 35 | class PreferencesDataBase(base.ClidDataBase): 36 | """Class to manage the settings/config file 37 | Attributes: 38 | _pref(configobj.ConfigObj): Stores clid's settings 39 | app(npyscreen.NPSAppManaged): Reference to parent application 40 | when_changed(WhenOptionChanged) 41 | """ 42 | def __init__(self, app): 43 | super().__init__(app) 44 | self.when_changed = WhenOptionChanged(app=self.app) 45 | self._pref = configobj.ConfigObj(const.CONFIG_DIR + 'clid.ini') 46 | 47 | # build status line help msg cache 48 | self.pref_help = {} 49 | for section in self._pref.values(): 50 | for option, help_msg in section.comments.items(): 51 | self.pref_help[option] = help_msg[0][2:] + ' ' # slice to remove `# ` 52 | 53 | def get_pref(self, option): 54 | """Return the current setting for `option` from General section""" 55 | return self._pref['General'][option] 56 | 57 | def get_key(self, action, return_str=False): 58 | """Return the key corresponding to `action` 59 | Args: 60 | return_str(bool): Always return a human-readable string, instead of 61 | int(returned if key is something like `insert`, `esc`, `end`, etc) 62 | """ 63 | key = self._pref['Keybindings'][action] 64 | if const.VALID_KEY_CHARS.fullmatch(key) or return_str: 65 | return key 66 | else: 67 | # key is something like space, tab, insert 68 | return const.VALID_KEYS_EXTRA[key] 69 | 70 | def get_section_names(self): 71 | """Return(list) names of sections in pref""" 72 | return self._pref.sections.copy() 73 | 74 | def get_values_to_display(self): 75 | """Return a list of strings which will be used to display the settings 76 | in the editing window 77 | """ 78 | disp_list = [] 79 | for section, prefs in self._pref.items(): 80 | disp_list.append(' ') 81 | disp_list.extend([section, len(section) * '-']) # * '-' for underlining 82 | # number of characters after which value of an option is displayed 83 | max_length = len(max(prefs.keys(), key=len)) + 3 # +3 is just to beautify 84 | 85 | for option, value in prefs.items(): 86 | # number of spaces to add so that all options are aligned correctly 87 | spaces = (max_length - len(option)) * ' ' 88 | disp_list.append(option + spaces + value) 89 | return disp_list 90 | 91 | def parse_info_for_status(self, str_needing_info): 92 | """Return a short description of `str_needing_info` 93 | Note: 94 | `str_needing_info` will be a preference like `vim_mode true`, as 95 | displayed in the preference window 96 | """ 97 | pref = str_needing_info.split(maxsplit=1)[0] # get only the pref, not value 98 | return self.pref_help[pref] 99 | 100 | def is_option_enabled(self, option): 101 | """Check whether `option` is set to 'true' or 'false', 102 | in preferences. 103 | Args: 104 | option(str): option to be checked, like vim_mode 105 | Returns: 106 | bool: True if enabled, False otherwise 107 | """ 108 | return True if self.get_pref(option) == 'true' else False 109 | 110 | @change_pref(section='General') 111 | def set_pref(self, option, new_value): 112 | """Change a setting. 113 | Args: 114 | option(str): Setting that is to be changed 115 | new_value(str): New value of the setting 116 | """ 117 | validators.validate(option, new_value) 118 | self._pref['General'][option] = new_value 119 | self.when_changed.run_hook(option) # changes take effect 120 | 121 | @change_pref(section='Keybindings') 122 | def set_key(self, action, key): 123 | """Change a keybinding. 124 | Args: 125 | action(str): Action that is to be changed. 126 | key(str): New keybinding 127 | """ 128 | if not self.get_key(action, return_str=True) == key: 129 | validators.validate_key( 130 | key=key, already_used_keys=self._pref['Keybindings'].values() 131 | ) 132 | self._pref['Keybindings'][action] = key 133 | self.when_changed.run_hook('keybinding') 134 | 135 | 136 | class WhenOptionChanged(): 137 | """Class containing function to be run when an option is changed so 138 | the app doesn't have to be relaunched to see the effects. 139 | Used *only* by PreferencesDataBase. 140 | """ 141 | def __init__(self, app): 142 | self.app = app 143 | 144 | def run_hook(self, option): 145 | """Run the function which correspond to option(str)""" 146 | getattr(self, option)() 147 | 148 | def vim_mode(self): 149 | pass # doesn't need anything 150 | 151 | def music_dir(self): 152 | self.app.mp3db.load_mp3_files_from_music_dir() 153 | self.app.getForm("MAIN").load_files_to_show() 154 | 155 | def preview_format(self): 156 | self.app.mp3db.load_preview_format() 157 | self.app.getForm("MAIN").wMain.set_current_status() 158 | # change current file's preview into new format 159 | 160 | def smooth_scroll(self): 161 | scroll_option = self.app.prefdb.is_option_enabled('smooth_scroll') 162 | self.app.getForm("MAIN").wMain.slow_scroll = scroll_option 163 | 164 | def use_regex_in_search(self): 165 | pass 166 | 167 | def mouse_support(self): 168 | self.app.configure_mouse_support() 169 | 170 | def keybinding(self): 171 | """Run when keybindings are changed""" 172 | self.app.getForm("MAIN").load_keys() 173 | self.app.getForm("SETTINGS").load_keys() 174 | -------------------------------------------------------------------------------- /clid/base/forms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Base classes for Forms""" 4 | 5 | import os 6 | 7 | import npyscreen as npy 8 | 9 | from clid import base 10 | from clid import util 11 | from clid import readtag 12 | 13 | 14 | class ClidForm(npy.FormBaseNew): 15 | """Base class for Forms""" 16 | # prevent npyscreen from setting minimum height and width of window to 17 | # initial size of window 18 | FIX_MINIMUM_SIZE_WHEN_CREATED = False 19 | 20 | def __init__(self, parentApp, *args, **kwargs): 21 | self.mp3db = parentApp.mp3db 22 | self.prefdb = parentApp.prefdb 23 | 24 | def load_keys(self): 25 | """Load user defined keybindings""" 26 | pass 27 | 28 | 29 | class ClidMuttForm(ClidForm, npy.FormMuttActiveTraditional): 30 | """Forms with a traditional mutt-like interface - content first, status line 31 | second, and command line at the bottom. 32 | 33 | >>> import clid.base 34 | >>> clid.base.forms.ClidMuttForm.mro() 35 | [clid.base.forms.ClidMuttForm, 36 | clid.base.forms.ClidForm, 37 | npyscreen.fmFormMuttActive.FormMuttActiveTraditional, 38 | ... 39 | ] 40 | """ 41 | def __init__(self, parentApp, *args, **kwargs): 42 | super().__init__(parentApp=parentApp, *args, **kwargs) 43 | super(ClidForm, self).__init__(parentApp=parentApp, *args, **kwargs) 44 | 45 | def show_notif(self, title, msg): 46 | """Show notification through the command line""" 47 | self.wCommand.show_notif(title, msg) 48 | 49 | def load_keys(self): 50 | get_key = self.prefdb.get_key 51 | self.handlers.update({ 52 | get_key('quit'): lambda *a, **k: exit(), 53 | get_key('preferences'): self.h_switch_to_settings, 54 | get_key('files_view'): self.h_switch_to_files_view, 55 | }) 56 | self.wMain.load_keys() 57 | 58 | def h_switch_to_settings(self, char): 59 | """Switch to Preferences View""" 60 | self.parentApp.switchForm("SETTINGS") 61 | 62 | def h_switch_to_files_view(self, char): 63 | """Go to Main View""" 64 | self.parentApp.switchForm("MAIN") 65 | 66 | @property 67 | def maindb(self): 68 | """The main db(prefdb, mp3db, etc) the form will be interacting with""" 69 | pass 70 | 71 | 72 | class ClidActionForm(ClidForm, npy.ActionFormV2): 73 | """Forms with two buttons at the bottom, usually labelled 'OK' and 'Cancel""" 74 | def __init__(self, parentApp, *args, **kwargs): 75 | super().__init__(parentApp=parentApp, *args, **kwargs) 76 | super(ClidForm, self).__init__(parentApp=parentApp, *args, **kwargs) 77 | 78 | def show_notif(self, title, msg): 79 | """Show notification in a popup""" 80 | npy.notify_confirm(message=msg, form_color=util.get_color(title), 81 | title=title, editw=1) 82 | 83 | 84 | class ClidEditMetaView(ClidActionForm): 85 | """Edit the metadata of a track. 86 | Attributes: 87 | files(list): List of files whose tags are being edited. 88 | in_insert_mode(bool): 89 | Indicates whether the form is in insert/normal 90 | mode(if vim_mode are enabled). This is actually 91 | set as an attribute of the parent form so that all 92 | text boxes in the form are in the same mode. 93 | """ 94 | OK_BUTTON_TEXT = 'Save' 95 | PRESERVE_SELECTED_WIDGET_DEFAULT = True # to remember last position 96 | 97 | def __init__(self, *args, **kwags): 98 | super().__init__(*args, **kwags) 99 | 100 | self.editw = self.parentApp.current_field # go to last used tag field 101 | self.in_insert_mode = False 102 | self.files = self.parentApp.current_files 103 | self.load_keys() 104 | 105 | def load_keys(self): 106 | get_key = self.prefdb.get_key 107 | self.handlers.update({ 108 | get_key('save_tags'): self.h_ok, 109 | get_key('cancel_saving_tags'): self.h_cancel 110 | }) 111 | 112 | def _get_textbox_cls(self): 113 | """Return tuple of classes(normal and genre) to be used as textbox input 114 | field, depending on the value of the setting `vim_mode` 115 | """ 116 | if self.prefdb.is_option_enabled('vim_mode'): 117 | tbox, gbox = base.ClidVimTextfield, base.ClidVimGenreTextfiled 118 | else: 119 | tbox, gbox = base.ClidTextfield, base.ClidGenreTextfield 120 | 121 | # make textboxes with labels 122 | class TitleTbox(npy.TitleText): 123 | _entry_type = tbox 124 | 125 | class TitleGbox(npy.TitleText): 126 | _entry_type = gbox 127 | 128 | return (TitleTbox, TitleGbox) 129 | 130 | def create(self): 131 | tbox, gbox = self._get_textbox_cls() 132 | self.tit = self.add(widgetClass=tbox, name='Title') 133 | self.nextrely += 1 134 | self.alb = self.add(widgetClass=tbox, name='Album') 135 | self.nextrely += 1 136 | self.art = self.add(widgetClass=tbox, name='Artist') 137 | self.nextrely += 1 138 | self.ala = self.add(widgetClass=tbox, name='Album Artist') 139 | self.nextrely += 2 140 | self.gen = self.add(widgetClass=gbox, name='Genre') 141 | self.nextrely += 1 142 | self.dat = self.add(widgetClass=tbox, name='Date/Year') 143 | self.nextrely += 1 144 | self.tno = self.add(widgetClass=tbox, name='Track Number') 145 | self.nextrely += 2 146 | self.com = self.add(widgetClass=tbox, name='Comment') 147 | 148 | def h_ok(self, char): 149 | """Handler to save the tags""" 150 | self.on_ok() 151 | 152 | def h_cancel(self, char): 153 | """Handler to cancel the operation""" 154 | self.on_cancel() 155 | 156 | def switch_to_main(self): 157 | """Switch to main view. Used by `on_cancel` (at once) and 158 | `on_ok` (after saving tags). 159 | """ 160 | self.editing = False 161 | self.parentApp.switchForm("MAIN") 162 | 163 | def get_fields_to_save(self): 164 | """Return a dict with name of tag as key and value of textbox 165 | with tag name as value; Eg: {'artist': value of artist textbox} 166 | """ 167 | pass 168 | 169 | def do_after_saving_tags(self): 170 | """Stuff to do after saving tags, like renaming the mp3 file. 171 | Overridden by child classes 172 | """ 173 | pass 174 | 175 | def on_ok(self): 176 | """Save and switch to standard view""" 177 | # date format check 178 | if not util.is_date_in_valid_format(self.dat.value): 179 | self.show_notif(msg='Date should be of the form YYYY-MM-DD HH:MM:SS', 180 | title='Error') 181 | return 182 | # track number check 183 | if not util.is_track_number_valid(self.tno.value): 184 | self.show_notif(msg='Track number can only take integer values', 185 | title='Error') 186 | return 187 | # FIXME: values of tags are reset to initial when ok is pressed(no prob 188 | # with ^S) 189 | 190 | tag_fields = self.get_fields_to_save().items() 191 | for mp3 in self.files: 192 | meta = readtag.ReadTags(mp3) 193 | for tag, value in tag_fields: 194 | setattr(meta, tag, value) 195 | meta.write(mp3) 196 | # update meta cache 197 | self.mp3db.parse_info_for_status( 198 | str_needing_info=os.path.basename(mp3), force=True 199 | ) 200 | 201 | # show the new tags of file under cursor in the status line 202 | self.parentApp.getForm("MAIN").wMain.set_current_status() 203 | self.do_after_saving_tags() 204 | 205 | self.parentApp.current_field = self._get_tbox_to_remember() 206 | self.switch_to_main() 207 | 208 | def on_cancel(self): 209 | """Switch to main view at once without saving""" 210 | self.parentApp.current_field = self._get_tbox_to_remember() 211 | self.switch_to_main() 212 | 213 | def _get_tbox_to_remember(self): 214 | """Return the int representing the textbox(tag field) to be remembered""" 215 | if self.editw > len(self._widgets__) - 3: # cursor is in ok/cancel button 216 | return len(self._widgets__) - 3 # return last textbox field 217 | return self.editw 218 | -------------------------------------------------------------------------------- /clid/forms/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Main View/Window of clid""" 4 | 5 | import npyscreen as npy 6 | 7 | from clid import base 8 | from clid import util 9 | from clid import const 10 | from clid import version 11 | 12 | 13 | class MainActionController(base.ClidActionController): 14 | """Object that recieves recieves inpout in command line 15 | at the bottom. 16 | Note: 17 | self.parent refers to MainView -> class 18 | """ 19 | def create(self): 20 | super().create() 21 | self.add_action('^/', self.search_for_files, live=True) # search with '/' 22 | self.add_action('^:mark', self.mark_item, live=False) 23 | 24 | def search_for_files(self, command_line, widget_proxy, live): 25 | search = command_line[1:] # first char will be '/' in command_line 26 | self.parent.wMain.values = self.parent.mp3db.get_filtered_values(search) 27 | if self.parent.wMain.values: 28 | self.parent.wMain.cursor_line = 0 29 | self.parent.wMain.set_current_status() # tag preview if a match is found 30 | else: 31 | self.parent.wStatus2.value = ' ' # show nothing if no files matched 32 | self.parent.after_search_now_filter_view = True 33 | self.parent.display() 34 | 35 | def mark_item(self, command_line, widget_proxy, live): 36 | pass 37 | 38 | class MainMultiLine(base.ClidMultiLine): 39 | """MultiLine class to be used by clid. `Esc` has been modified to revert 40 | the screen back to the normal view after a searh has been performed 41 | (the search results will be shown; blank if no matches are found) 42 | or if files have been selected. If files are selected *and* search 43 | has been performed, selected files will be kept intact and search will 44 | be reverted 45 | Attributes: 46 | space_selected_values(set): 47 | Stores set of files which was selected for batch tagging. A set is 48 | used as we don't want the same file to be added more than once 49 | Note: 50 | self.parent refers to MainView -> class 51 | """ 52 | def __init__(self, *args, **kwargs): 53 | super().__init__(*args, **kwargs) 54 | self.allow_filtering = False # does NOT refer to search invoked with '/' 55 | self.space_selected_values = set() 56 | 57 | self.slow_scroll = self.parent.prefdb.is_option_enabled('smooth_scroll') 58 | 59 | def load_keys(self): 60 | super().load_keys() 61 | get_key = self.parent.prefdb.get_key 62 | self.handlers.update({ 63 | get_key('esc_key'): self.h_revert_escape, 64 | get_key('select_item'): self.h_multi_select, 65 | get_key('reload_music_dir'): self.h_reload_files, 66 | get_key('invert_selection'): self.h_invert_selection, 67 | }) 68 | 69 | def get_relative_index_of_space_selected_values(self): 70 | """Return list of indexes of space selected files, 71 | *compared to self.parent.wMain.values* 72 | """ 73 | return [self.values.index(file) for file in self.space_selected_values 74 | if file in self.values] 75 | 76 | # Handlers 77 | def h_reload_files(self, char): 78 | """Reload files in `music_dir`""" 79 | self.parent.mp3db.load_mp3_files_from_music_dir() 80 | self.parent.load_files_to_show() 81 | 82 | def h_revert_escape(self, char): 83 | """Handler which switches from the filtered view of search results 84 | to the normal view with the complete list of files, if search results 85 | are being displayed. If all files are being shown, empty 86 | `space_selected_values` to clear multi file selection 87 | """ 88 | if self.parent.after_search_now_filter_view: 89 | self.values = self.parent.mp3db.get_values_to_display() # revert 90 | self.parent.after_search_now_filter_view = False 91 | self.set_current_status() 92 | elif self.space_selected_values: # if files have been selected with space 93 | self.space_selected_values = set() 94 | self.display() 95 | 96 | @util.run_if_window_not_empty(update_status_line=False) 97 | def h_select(self, char): 98 | """Select a file using (default)""" 99 | if self.space_selected_values: 100 | # add the file under cursor if it is not already in it 101 | self.space_selected_values.add(self.get_selected()) 102 | self.parent.parentApp.set_current_files(self.space_selected_values) 103 | self.space_selected_values = set() 104 | self.parent.parentApp.switchForm("MULTIEDIT") 105 | else: 106 | self.parent.parentApp.set_current_files([self.get_selected()]) 107 | self.parent.parentApp.switchForm("SINGLEEDIT") 108 | 109 | @util.run_if_window_not_empty(update_status_line=False) 110 | def h_multi_select(self, char): 111 | """Add or remove current line from list of lines to be highlighted 112 | (for batch tagging) when is pressed. 113 | """ 114 | current = self.get_selected() 115 | try: 116 | self.space_selected_values.remove(current) # unhighlight file 117 | except KeyError: 118 | self.space_selected_values.add(current) # highlight file 119 | 120 | @util.run_if_window_not_empty(update_status_line=False) 121 | def h_invert_selection(self, char): 122 | """Invert selection made using """ 123 | self.space_selected_values = set(self.values) - self.space_selected_values 124 | 125 | 126 | # HACK: Following two funcions are actually used by npyscreen to display filtered 127 | # values based on a search string, by highlighting the results. This is a 128 | # hack that makes npyscreen highlight files that have been selected with 129 | # , instead of highlighting search results 130 | 131 | def filter_value(self, index): 132 | return self._filter in self.display_value(self.values[index]).lower 133 | 134 | def _set_line_highlighting(self, line, value_indexer): 135 | """Highlight files which were selected with """ 136 | if value_indexer in self.get_relative_index_of_space_selected_values(): 137 | self.set_is_line_important(line, True) # mark as important(bold) 138 | else: 139 | self.set_is_line_important(line, False) 140 | # without this line every file will get highlighted as we go down 141 | self.set_is_line_cursor(line, False) 142 | 143 | 144 | class MainView(base.ClidMuttForm): 145 | """The main app with the ui. 146 | Attributes: 147 | after_search_now_filter_view(bool): 148 | Used to revert screen(ESC) to standard view after a search 149 | (see class MainMultiLine) 150 | mp3db: Reference to mp3db(see __main__.ClidApp) 151 | prefdb: Reference to prefdb(see __main__.ClidApp) 152 | Note: 153 | self.value refers to an instance of DATA_CONTROLER 154 | """ 155 | MAIN_WIDGET_CLASS = MainMultiLine 156 | ACTION_CONTROLLER = MainActionController 157 | COMMAND_WIDGET_CLASS = base.ClidCommandLine 158 | 159 | def __init__(self, parentApp, *args, **kwargs): 160 | super().__init__(parentApp=parentApp, *args, **kwargs) 161 | self.load_keys() 162 | 163 | self.after_search_now_filter_view = False 164 | self.load_files_to_show() 165 | self.wStatus1.value = 'clid v' + version.VERSION + ' ' 166 | 167 | with open(const.CONFIG_DIR + 'first', 'r') as file: 168 | first = file.read() 169 | 170 | if first == 'true': 171 | # if app is run after an update, display a what's new message 172 | with open(const.CONFIG_DIR + 'NEW') as new: 173 | disp = new.read() 174 | npy.notify_confirm(message=disp, title="What's New", editw=1) 175 | with open(const.CONFIG_DIR + 'first', 'w') as file: 176 | file.write('false') 177 | 178 | @property 179 | def maindb(self): 180 | return self.mp3db 181 | 182 | def load_files_to_show(self): 183 | """Set the mp3 files that will be displayed""" 184 | self.wMain.values = self.mp3db.get_values_to_display() 185 | # display tag preview of first file 186 | try: 187 | self.wMain.cursor_line = 0 188 | self.wMain.set_current_status() 189 | except IndexError: # thrown if directory doest not have mp3 files 190 | self.wStatus2.value = 'No Files Found In Directory ' 191 | -------------------------------------------------------------------------------- /clid/base/widgets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Base classes for Widgets""" 4 | 5 | import curses 6 | import weakref 7 | 8 | import npyscreen as npy 9 | 10 | from clid import util 11 | from clid import const 12 | 13 | 14 | class ClidWidget(npy.widget.Widget): 15 | """Base class for widgets""" 16 | def remove_handler_keys(self, keys_to_remove): 17 | """Remove `keys_to_remove` from list of handlers 18 | Args: 19 | keys_to_remove(iterable): Keys to remove. 20 | Eg: keys_to_remove = ('l', curses.KEY_END) 21 | """ 22 | for key in keys_to_remove: 23 | del self.handlers[key] 24 | 25 | def load_keys(self): 26 | """Load user defined keybindings""" 27 | pass 28 | 29 | 30 | class ClidTextfield(npy.wgtextbox.Textfield, ClidWidget): 31 | """Normal textbox with home and end keys working""" 32 | def set_up_handlers(self): 33 | super().set_up_handlers() 34 | self.add_handlers({ 35 | curses.KEY_END: self.h_end, 36 | curses.KEY_HOME: self.h_home 37 | }) 38 | 39 | def h_home(self, char): 40 | """Home Key""" 41 | self.cursor_position = 0 42 | 43 | def h_end(self, char): 44 | """End Key""" 45 | self.cursor_position = len(self.value) 46 | 47 | 48 | class ClidVimTextfield(ClidTextfield): 49 | """Textfield class to be used as input boxes for tag fields when editing tags 50 | if vim mode is enabled. 51 | Attributes: 52 | vim_handlers(dict): dict of key mappings with key: handler. 53 | """ 54 | def __init__(self, screen, *args, **kwargs): 55 | self._vim_normal_mode_handlers = { 56 | # movement 57 | 'k': self.h_exit_up, 58 | 'j': self.h_exit_down, 59 | 'h': self.h_cursor_left, 60 | 'l': self.h_cursor_right, 61 | curses.ascii.SP: self.h_cursor_right, # Space 62 | curses.KEY_BACKSPACE: self.h_cursor_left, 63 | # deletion 64 | 'X': self.h_delete_left, 65 | 'x': self.h_delete_right, 66 | # insert chars 67 | 'i': self.h_vim_insert_mode, 68 | 'a': self.h_vim_append_char, 69 | 'A': self.h_vim_append_char_at_end, 70 | } 71 | super().__init__(screen, *args, **kwargs) 72 | # set_up_handlers is called in __init__ 73 | 74 | def set_up_handlers(self): 75 | super().set_up_handlers() 76 | self.vim_start_normal_mode() 77 | self.add_handlers({ 78 | self.parent.prefdb.get_key('esc_key'): self.h_vim_normal_mode 79 | }) 80 | 81 | def vim_start_normal_mode(self): 82 | """Enter NORMAL mode and add NORMAL mode handlers""" 83 | self.add_handlers(self._vim_normal_mode_handlers) 84 | 85 | def vim_start_insert_mode(self): 86 | """Enter INSERT mode and remove NORMAL mode handlers so that 87 | `j`, `k`, etc, text input characters 88 | """ 89 | self.remove_handler_keys(self._vim_normal_mode_handlers.keys()) 90 | # revert backspace to what it normally does 91 | self.handlers[curses.KEY_BACKSPACE] = self.h_delete_left 92 | 93 | def h_addch(self, inp): 94 | """Add characters only if in insert mode""" 95 | if self.parent.in_insert_mode: 96 | super().h_addch(inp) 97 | 98 | def h_vim_insert_mode(self, char): 99 | """Enter insert mode""" 100 | self.parent.in_insert_mode = True 101 | self.vim_start_insert_mode() 102 | 103 | def h_vim_normal_mode(self, char): 104 | """Exit insert mode by pressing user-defined key(by default ESC)""" 105 | if self.parent.in_insert_mode: 106 | self.parent.in_insert_mode = False 107 | self.cursor_position -= 1 # just like in vim 108 | self.vim_start_normal_mode() # removed earlier when going to insert mode 109 | 110 | def h_vim_append_char(self, char): 111 | """Append characters, like `a` in vim""" 112 | self.h_vim_insert_mode(char) 113 | self.cursor_position += 1 114 | 115 | def h_vim_append_char_at_end(self, char): 116 | """Add characters to the end of the line, like `A` in vim""" 117 | self.h_vim_insert_mode(char) 118 | self.h_end(char) # go to the end 119 | 120 | 121 | class ClidGenreTextfield(ClidTextfield, npy.Autocomplete): 122 | """Special textbox for genre tag with autocompleting""" 123 | def __init__(self, *args, **kwargs): 124 | super().__init__(*args, **kwargs) 125 | self.genres = [genre.lower() for genre in const.GENRES] 126 | self.handlers.update({ 127 | curses.ascii.TAB: self.h_auto_complete 128 | }) 129 | 130 | def h_auto_complete(self, char): 131 | """Attempt to auto-complete genre""" 132 | value = self.value.lower() 133 | complete_list = [genre.title() for genre in self.genres if value in genre] 134 | if len(complete_list) is 1: 135 | self.value = complete_list[0] 136 | else: 137 | self.value = complete_list[self.get_choice(complete_list)] 138 | self.cursor_position = len(self.value) 139 | 140 | 141 | class ClidVimGenreTextfiled(ClidVimTextfield, ClidGenreTextfield): 142 | """Like ClidGenreTextfield, but with vim keybindings""" 143 | def __init__(self, *args, **kwargs): 144 | ClidVimTextfield.__init__(self, *args, **kwargs) 145 | ClidGenreTextfield.__init__(self, *args, **kwargs) 146 | 147 | 148 | class ClidCommandLine(npy.fmFormMuttActive.TextCommandBoxTraditional, ClidTextfield): 149 | """Command line shown at bottom of screen""" 150 | 151 | # TODO: HistoryDB with _search_history(deque) and _command_history 152 | # TODO: pickle the data structs for history and reload on startup 153 | 154 | def __init__(self, *args, **kwargs): 155 | super().__init__(*args, **kwargs) 156 | self.prev_msg = None 157 | 158 | def set_value(self, value): 159 | """Set text and place cursor at the end""" 160 | self.value = value 161 | self.cursor_position = len(self.value) 162 | self.display() 163 | 164 | def when_value_edited(self): 165 | if self.value != self.prev_msg: 166 | self.show_bold = False 167 | self.color = 'DEFAULT' 168 | super().when_value_edited() 169 | 170 | def show_notif(self, title, msg): 171 | """Show a notification(msg) with text color set to `color`""" 172 | self.color = util.get_color(title) 173 | self.show_bold = True 174 | self.prev_msg = '({title}): {message}'.format(title=title, message=msg) 175 | self.set_value(self.prev_msg) 176 | self.editable = False # msg is now not editable; reverted when enter is pressed 177 | 178 | def h_execute_command(self, *args, **keywords): 179 | if self.history and self.value.startswith(':'): 180 | self._history_store.append(self.value) 181 | self._current_history_index = False 182 | 183 | command = self.value 184 | self.value = '' 185 | self.parent.action_controller.process_command_complete(command, weakref.proxy(self)) 186 | else: 187 | # notification is being displayed now; remove it 188 | self.editable = True 189 | self.value = '' 190 | 191 | 192 | class ClidMultiLine(npy.MultiLine, ClidWidget): 193 | """MultiLine class used for showing files and prefs""" 194 | def __init__(self, *args, **kwargs): 195 | super().__init__(*args, **kwargs) 196 | self.slow_scroll = self.parent.prefdb.is_option_enabled('smooth_scroll') 197 | self.remove_handler_keys(keys_to_remove=( 198 | curses.KEY_END, # goto_bottom 199 | curses.KEY_HOME, # goto_top 200 | curses.KEY_PPAGE, # page_up 201 | curses.KEY_NPAGE, # page_down 202 | )) 203 | 204 | def load_keys(self): 205 | get_key = self.parent.prefdb.get_key 206 | self.add_handlers({ 207 | get_key('page_up'): self.h_cursor_page_up, 208 | get_key('page_down'): self.h_cursor_page_down, 209 | get_key('goto_bottom'): self.h_cursor_end, 210 | get_key('goto_top'): self.h_cursor_beginning, 211 | }) 212 | 213 | def set_current_status(self, *args, **kwargs): 214 | """Show additional information about the thing under the cursor""" 215 | data = self.parent.maindb.parse_info_for_status( 216 | str_needing_info=self.get_selected(), *args, **kwargs 217 | ) 218 | self.parent.wStatus2.value = data 219 | self.parent.display() 220 | 221 | def get_selected(self): 222 | """Return the item under the cursor line""" 223 | return self.values[self.cursor_line] 224 | 225 | # Movement Handlers 226 | 227 | @util.run_if_window_not_empty(update_status_line=True) 228 | def h_cursor_page_up(self, char): 229 | super().h_cursor_page_up(char) 230 | 231 | @util.run_if_window_not_empty(update_status_line=True) 232 | def h_cursor_page_down(self, char): 233 | super().h_cursor_page_down(char) 234 | 235 | @util.run_if_window_not_empty(update_status_line=True) 236 | def h_cursor_line_up(self, char): 237 | super().h_cursor_line_up(char) 238 | 239 | @util.run_if_window_not_empty(update_status_line=True) 240 | def h_cursor_line_down(self, char): 241 | super().h_cursor_line_down(char) 242 | 243 | @util.run_if_window_not_empty(update_status_line=True) 244 | def h_cursor_beginning(self, char): 245 | super().h_cursor_beginning(char) 246 | 247 | @util.run_if_window_not_empty(update_status_line=True) 248 | def h_cursor_end(self, char): 249 | super().h_cursor_end(char) 250 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # clid 2 | 3 | Clid is an app for editing metadata(tags) of mp3 files without leaving the coziness of the terminal ;) 4 | Clid differs from other command line tools to edit tags, as it provides a curses based ui. 5 | 6 | ## Installation 7 | 8 | You're gonna need two things before you install the app: 9 | - Python**3** 10 | - Pip(python's package manager)[optional] 11 | 12 | Pip will be installed by default if Python version is > 3.4 (`python --version`). 13 | Else you will have to install it manually. 14 | 15 | 16 | You can use your package manager to install them. 17 | 18 | ### Using Pip 19 | 20 | ```shell 21 | $ [sudo] pip install clid 22 | ``` 23 | 24 | ### From Source(Without Pip) 25 | 26 | ```shell 27 | $ git clone https://github.com/GokulSoumya/clid.git 28 | $ cd clid 29 | $ [sudo] python3 setup.py install 30 | ``` 31 | 32 | ### Updating 33 | 34 | To update the app, run 35 | 36 | ```shell 37 | $ [sudo] pip install -U clid 38 | ``` 39 | 40 | ## Launching The App 41 | 42 | Type `clid` in the command line to start the app(and `:q` in the main window to quit): 43 | 44 | ![clid main window](main.png "Main Window") 45 | 46 | ## Quick Start 47 | 48 | 1. Move with arrow keys or `j` and `k`. 49 | 2. Enter to select a file. 50 | 3. Edit the tags. 51 | 4. `OK` to save the tags or `Cancel` to abort edit. 52 | 5. Type `:q` at main window to quit. 53 | 54 | ## Main Window 55 | 56 | Main window has 3 parts: 57 | 58 | ![clid main annnotated](main_annotated.png) 59 | 60 | 1. [File viewer](#file-viewer), showing files in `~/Music` by default, 61 | 2. [Status line](#status-line), showing live preview of tags of file under cursor, 62 | 3. [Command line](#command-line), which accepts commands. 63 | 64 | ### File Viewer 65 | 66 | You can see the mp3 files in the [selected directory](#available-options) in the main window. 67 | Files are read every time the app is started. You can use UpArrow, DownArrow, 68 | j, k, Home, PageUp, etc to move around. Hit Enter 69 | when you've found the file you want to [edit](#tagging-individual-files), or 70 | [tag multiple files in one go](#tagging-multiple-files-at-once). You can also [search for files](#searching-for-files). 71 | 72 | #### Searching For Files 73 | 74 | You can search for files by pressing /. Note that this is only a basic search - it doesn't search the tags 75 | of every file, only the filename is checked. 76 | 77 | 1. Press /. 78 | 2. Enter the search term. Results are shown as you type. 79 | 3. Press Enter to terminate search and navigate search results. 80 | 4. Press Esc to return to normal view. 81 | 82 | Your selections for batch tagginf(if any) are kept intact when searching. 83 | [See a note](#esc-key) on the Esc key. 84 | 85 | > To use regular expressions in your search; set the `use_regex_in_search` option to `true` 86 | 87 | ### Status Line 88 | 89 | ![tag preview](tag_preview_default.png) 90 | 91 | The status line shows a live preview of metadata of file under cursor in the 92 | [specified format](#customizing-tag-preview-format). The default format is 93 | `artist - album - track_number title`. 94 | 95 | ### Command Line 96 | 97 | You can execute commands and perform searches from here. Press `:` to enter [commands](#available-commands) 98 | and `/` to [search for files](#searching-for-files). 99 | 100 | ## Editing Tags 101 | 102 | ### Tagging Individual Files 103 | 104 | 1. Select the file you want to edit with Enter. 105 | 2. Edit the tags as required. You can also change the name of the file here. The extension(`.mp3`) 106 | isn't shown. 107 | 108 | > When editing the genre field, pressing tab will auto-complete the genre. If there are more 109 | > than one match, a dropdown will be showed, from which you can select one using Enter 110 | 111 | 3. You can then press `OK` to save the changes or `Cancel` to discard changes. Default keybindings for 112 | saving tags is Ctrl + S and canceling is Ctrl + W. 113 | 114 | > The tag editor remembers the field in which the cursor was in the last time and places it in the same field. 115 | 116 | ### Tagging Multiple Files At Once 117 | 118 | You can batch tag files in clid: 119 | 120 | 1. Select and deselect files with Space and press Enter to edit the files. *Note that 121 | pressing Enter will also add the file currently under the cursor to list of files that will be edited*. You can also search for files and then add them to the list of files to be edited. 122 | 123 | > Note: Press Esc to discard selections. [See a note](#esc-key) on the Esc key. 124 | 125 | ![clid batch tag selecting](batch_tag_main.png) 126 | 127 | 2. You will see a window with blank tag fields. Only the tag fields which you modify here will be saved to the 128 | files, that is, if this is what you have, 129 | 130 | ![clid batch tagging](batch_tag_edit.png) 131 | 132 | then since Album and Artist are the only fields with text, only those will be written to the selected mp3 files. 133 | 134 | 3. You can save or cancel as mentioned above. 135 | 136 | > You can invert your selection by using the i keybinding. 137 | 138 | > If no previous selection has been made, you can use i to select every file. 139 | 140 | ## Editing Preferences 141 | 142 | > Config file is located at `~/.config/clid/clid.ini`. 143 | 144 | You can edit preferences by pressing 2. A short description of various options 145 | and keybindings are displayed in the status line. 146 | 147 | 1. Select the option you want to edit and press Enter. 148 | 2. There will be a prompt in the command line; edit the option and press Enter. 149 | 3. If you gave an invalid value, an error message will be shown. 150 | 151 | ### Available Options 152 | 153 | | Option | Description | Default Value | Acceptable Values | 154 | |:--------:|-------|:---------:|----------| 155 | | `music_dir` | Directory in which the app will search for mp3 files recursively | `~/Music` | Any valid path | 156 | | `preview_format` | Format in which a preview of the file under cursor will be shown | `%a - %l - %n. %t` | See [list of valid format specifiers](#customizing-tag-preview-format) | 157 | | `smooth_scroll` | Enable or disable smooth scroll | `true` | `true` / `false` | 158 | | `vim_mode` | Enable or disable Vim style keybindings | `false` | `true` / `false` | 159 | | `use_regex_in_search` | Enable or disable regular expressions when searching | `false` | `true` / `false` | 160 | 161 | #### Vim Mode 162 | 163 | Vim style keybindings can be enabled, which currently supports basic stuff like adding, inserting and deleting text. 164 | [See a note](#esc-key) on the Esc key. 165 | 166 | #### Customizing Tag Preview Format 167 | 168 | The preview format can be edited using format specifiers: 169 | 170 | | Format Specifier | Expands to | 171 | |:----------------:|:-------------:| 172 | | %t | Title | 173 | | %a | Artist | 174 | | %l | Album | 175 | | %n | Track Number | 176 | | %c | Comment | 177 | | %A | Album Artist | 178 | | %y | Date | 179 | | %g | Genre | 180 | 181 | Example: `%a - %l [%n] %t (%y)` expands to `Artist - Album [Track Number] Title (Date)` 182 | 183 | ### Keybindings 184 | 185 | You can bind any printable character to an action. To use keys like Ctrl + A, 186 | use `^A` notation. 187 | 188 | Other keys like Space, Insert, etc are recognized by these names: 189 | 190 | | Name | Key | 191 | |:------:|:-----:| 192 | |`esc` | Escape | 193 | |`tab` | Tab | 194 | |`end` | End | 195 | |`home` | Home | 196 | |`space` | Space | 197 | |`insert` | Insert | 198 | |`delete` | Delete | 199 | |`page_up` | Page Up | 200 | |`page_down` | Page Down | 201 | 202 | #### Available Keybindings 203 | 204 | | Option | Description | Default Key | 205 | |:-------:|------------|:-----------:| 206 | | `files_view` | Switch to Files View | 1 | 207 | | `preferences` | Edit Preferences | 2 | 208 | | `save_tags` | Save tags after modifying them(Save button) | ^S | 209 | | `cancel_saving_tags` | Go back to Files without saving modified tags(Cancel button) | ^W | 210 | | `select_item` | Select item(file) for batch tagging or similar stuff | space | 211 | | `invert_selection` | Invert selection made with `select_item` | i | 212 | | `reload_music_dir` | Refresh file list from directory | u | 213 | | `goto_top` | Goto the top of the list(first item) | home | 214 | | `goto_bottom` | Goto the bottom of the list(last item) | end | 215 | | `page_up` | Page up | page_up | 216 | | `page_down` | Page down | page_down | 217 | | `esc_key` | Key to be treated as Escape(The Esc key is a bit slow) | esc | 218 | | `quit` | Quit the app | ^Q | 219 | 220 | ## Available Commands 221 | 222 | Press `:` to start entering commands. 223 | 224 | 225 | 226 | `set