├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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