├── TODO ├── fo.conf.template ├── fileorganizer.plugin ├── .gitignore ├── uninstall.py ├── LICENSE ├── install.py ├── AUTHORS ├── depends_test.py ├── Makefile ├── configurator.py ├── logops.py ├── tools.py ├── README.md ├── config.ui ├── fileorganizer.py └── fileops.py /TODO: -------------------------------------------------------------------------------- 1 | testing plan 2 | -------------------------------------------------------------------------------- /fo.conf.template: -------------------------------------------------------------------------------- 1 | [conf] 2 | cleanup_empty_folders = True 3 | cleanup_enabled = True 4 | log_path = .fileorganizer.log 5 | log_enabled = True 6 | preview_mode = False 7 | strip_ntfs = False 8 | 9 | -------------------------------------------------------------------------------- /fileorganizer.plugin: -------------------------------------------------------------------------------- 1 | [Plugin] 2 | Loader=python3 3 | Module=fileorganizer 4 | IAge=2 5 | Depends=rb 6 | Name=File Organizer 7 | Description=A music file and folder organizer 8 | Authors=Lachlan de Waard , Wolter Hellmund 9 | Copyright=Copyright © 2010 Wolter Hellmund 10 | Website=https://github.com/lachlan-00/rb-fileorganizer 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | #Geany 30 | *.geany 31 | 32 | fo.conf 33 | -------------------------------------------------------------------------------- /uninstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ FileOrganizer Uninstall Script 4 | 5 | Remove files from the plugin folder 6 | 7 | """ 8 | 9 | import os 10 | import shutil 11 | 12 | INSTALLPATH = os.path.join(os.getenv('HOME'), 13 | ".local/share/rhythmbox/plugins/fileorganizer") 14 | TEMPLATEPATH = os.path.join(INSTALLPATH, 'template') 15 | 16 | if os.path.isdir(INSTALLPATH): 17 | shutil.rmtree(INSTALLPATH) 18 | print('\nFileOrganizer is uninstalled\n') 19 | else: 20 | print('\nFileOrganizer is not installed\n') 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. 2 | 3 | BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS. 4 | 5 | http://creativecommons.org/licenses/by-sa/3.0/ 6 | -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ FileOrganizer Safe Install Script 4 | 5 | Install if dependencies are satisfied 6 | 7 | """ 8 | 9 | import os 10 | import shutil 11 | 12 | import depends_test 13 | 14 | INSTALLPATH = os.path.join(os.getenv('HOME'), 15 | ".local/share/rhythmbox/plugins/fileorganizer") 16 | 17 | # The depends test will check for required modules 18 | if depends_test.check(): 19 | # check plugin directory 20 | if not os.path.exists(INSTALLPATH): 21 | os.makedirs(INSTALLPATH) 22 | # copy the contents of the plugin directory 23 | for i in os.listdir('./'): 24 | if os.path.isfile(i): 25 | print('Copying... ' + i) 26 | shutil.copy(i, INSTALLPATH) 27 | print('\nFileOrganizer is now installed\n') 28 | else: 29 | print('please check your OS for missing packages') 30 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Wolter Hellmund 2 | Original Author 3 | Everything up to 1.0.1 & Revision 7 [1] 4 | 5 | Sharpeee [https://launchpad.net/~sharpeee] 6 | Implemented database update upon file relocation. [2] 7 | 8 | Fayez [https://github.com/sirfz] 9 | added strip_ntfs. [5] 10 | 11 | alzadude [https://github.com/alzadude] 12 | General code fixes 13 | Multiple library awareness [12] [13] 14 | 15 | Lachlan de Waard 16 | 1.0.2 & Revision 8 onwards. [3] 17 | GTK3 port and current code. [4] 18 | Migrated to github [6] 19 | 20 | 21 | [1] http://code.launchpad.net/~wolterh/rb-fileorganizer/main 22 | [2] http://bugs.launchpad.net/rb-fileorganizer/+bug/575964 23 | [3] http://code.launchpad.net/~lachlan-00/rb-fileorganizer/legacy 24 | [4] http://code.launchpad.net/~lachlan-00/rb-fileorganizer/trunk 25 | [5] https://github.com/lachlan-00/rb-fileorganizer/pull/8 26 | [6] https://github.com/lachlan-00/rb-fileorganizer/ 27 | [7] https://github.com/lachlan-00/rb-fileorganizer/pull/12 28 | [8] https://github.com/lachlan-00/rb-fileorganizer/pull/13 29 | 30 | -------------------------------------------------------------------------------- /depends_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Fileorganizer: test your dependencies 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | ----------------Licence---------------- 8 | Creative Commons - Attribution Share Alike v3.0 9 | 10 | """ 11 | 12 | 13 | def check(): 14 | """ Importing all libraries used by FileOrganizer """ 15 | clear = False 16 | try: 17 | import os 18 | import codecs 19 | import configparser 20 | import shutil 21 | import subprocess 22 | import time 23 | import gi 24 | import urllib.parse 25 | 26 | gi.require_version('Peas', '1.0') 27 | gi.require_version('PeasGtk', '1.0') 28 | gi.require_version('Notify', '0.7') 29 | gi.require_version('RB', '3.0') 30 | 31 | from gi.repository import GObject, Peas, PeasGtk, Gtk, Notify, Gio 32 | from gi.repository import RB 33 | 34 | clear = True 35 | except ImportError as errormsg: 36 | print('\nDependency Problem\n\n' + str(errormsg)) 37 | 38 | if clear: 39 | print('\nAll FileOrganizer dependencies are satisfied\n') 40 | return True 41 | else: 42 | return False 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | INSTALLPATH="$(HOME)/.local/share/rhythmbox/plugins/fileorganizer/" 2 | INSTALLTEXT="The Fileorganizer plugin has been installed. You may now restart Rhythmbox and enable the 'Fileorganizer' plugin." 3 | UNINSTALLTEXT="The Fileorganizer plugin had been removed. The next time you restart Rhythmbox it will dissappear from the plugins list." 4 | PLUGINFILE="fileorganizer.plugin" 5 | 6 | install-req: 7 | # Make environment 8 | mkdir -p $(INSTALLPATH) 9 | # Copy files, forcefully 10 | cp $(PLUGINFILE) $(INSTALLPATH) -f 11 | cp *.py $(INSTALLPATH) -f 12 | cp config.ui $(INSTALLPATH) -f 13 | cp fo.conf.template $(INSTALLPATH) -f 14 | cp README.md $(INSTALLPATH) -f 15 | cp LICENSE $(INSTALLPATH) -f 16 | cp AUTHORS $(INSTALLPATH) -f 17 | 18 | install: install-req 19 | @echo 20 | @echo $(INSTALLTEXT) 21 | 22 | install-gui: install-req 23 | # Notify graphically 24 | zenity --info --title='Installation complete' --text=$(INSTALLTEXT) 25 | 26 | uninstall-req: 27 | # Simply remove the installation path folder 28 | rm -rf $(INSTALLPATH) 29 | 30 | uninstall: uninstall-req 31 | @echo 32 | @echo $(UNINSTALLTEXT) 33 | 34 | uninstall-gui: uninstall-req 35 | # Notify graphically 36 | zenity --info --title='Uninstall complete' --text=$(UNINSTALLTEXT) 37 | 38 | -------------------------------------------------------------------------------- /configurator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Configuration (gsettings) handler for Fileorganizer 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | Wolter Hellmund 8 | ----------------Licence---------------- 9 | Creative Commons - Attribution Share Alike v3.0 10 | 11 | """ 12 | 13 | 14 | from gi.repository import Gio 15 | 16 | # gsettings locations for library and output paths 17 | RHYTHMBOX_RHYTHMDB = 'locations' 18 | RHYTHMBOX_LIBRARY = {'layout-path', 'layout-filename'} 19 | 20 | 21 | class FileorganizerConf(object): 22 | """ Class to read RB values using dconf/gsettings """ 23 | def __init__(self): 24 | self.rhythmdbsettings = Gio.Settings("org.gnome.rhythmbox.rhythmdb") 25 | self.librarysettings = Gio.Settings("org.gnome.rhythmbox.library") 26 | 27 | # Request value 28 | def get_val(self, key): 29 | """ Fill values according to the current value in gsettings """ 30 | keypath = None 31 | if key == RHYTHMBOX_RHYTHMDB: 32 | return self.rhythmdbsettings[key] 33 | elif key in RHYTHMBOX_LIBRARY: 34 | return self.librarysettings[key] 35 | else: 36 | print('Invalid key requested') 37 | return keypath 38 | -------------------------------------------------------------------------------- /logops.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Fileorganizer log operations 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | ----------------Licence---------------- 8 | Creative Commons - Attribution Share Alike v3.0 9 | 10 | """ 11 | 12 | 13 | import os 14 | import codecs 15 | import configparser 16 | 17 | 18 | class LogFile(object): 19 | """ Log file actions. Open, create and edit log files """ 20 | def __init__(self): 21 | self.conf = configparser.RawConfigParser() 22 | conffile = (os.getenv('HOME') + '/.local/share/rhythmbox/' + 23 | 'plugins/fileorganizer/fo.conf') 24 | self.conf.read(conffile) 25 | 26 | # Write to log file 27 | def log_processing(self, logmessage): 28 | """ Perform log operations """ 29 | log_enabled = self.conf.get('conf', 'log_enabled') 30 | log_filename = self.conf.get('conf', 'log_path') 31 | log_filename = os.getenv('HOME') + '/' + log_filename 32 | # Log if Enabled 33 | if log_enabled == 'True': 34 | # Create if missing 35 | if (not os.path.exists(log_filename) or 36 | os.path.getsize(log_filename) >= 1076072): 37 | files = codecs.open(log_filename, "w", "utf8") 38 | files.close() 39 | files = codecs.open(log_filename, "a", "utf8") 40 | try: 41 | logline = [logmessage] 42 | files.write((u"".join(logline)) + u"\n") 43 | except UnicodeDecodeError: 44 | print('LOG UNICODE ERROR') 45 | logline = [logmessage.decode('utf-8')] 46 | files.write((u"".join(logline)) + u"\n") 47 | files.close() 48 | -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Fileorganizer tools 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | Wolter Hellmund 8 | ----------------Licence---------------- 9 | Creative Commons - Attribution Share Alike v3.0 10 | 11 | """ 12 | 13 | import os 14 | import subprocess 15 | 16 | import fileops 17 | 18 | 19 | class LibraryLocationError(Exception): 20 | """To be raised when a file:// library location could not be found""" 21 | 22 | 23 | # Returns the library location for a file, 24 | # or the default location if the file is not inside any library location 25 | # Raises an error if there are no file:// locations in the library 26 | def library_location(files, library_locations): 27 | file_locations = list(l for l in library_locations if l.startswith('file://')) 28 | if not file_locations: 29 | raise LibraryLocationError('No file:// locations could be found in the library') 30 | return next((l for l in file_locations if files.location.startswith(l)), 31 | file_locations[0]) 32 | 33 | 34 | # Create a folder inside a library path if non-existent, and return it 35 | def folderize(library_path, folder): 36 | """ Create folders for file operations """ 37 | dirpath = library_path + '/' 38 | # Strip full stops from paths 39 | folder = folder.replace('/.', '/_') 40 | if not os.path.exists(dirpath + folder): 41 | os.makedirs(dirpath + folder) 42 | return os.path.normpath(dirpath + folder) 43 | 44 | 45 | # Replace the placeholders with the correct values 46 | def data_filler(files, string, strip_ntfs=False): 47 | """ replace string data with metadata from current item """ 48 | string = str(string) 49 | for key in fileops.RB_METATYPES: 50 | if '%' + key in string: 51 | if key == 'aa': 52 | artisttest = files.get_metadata('aa') 53 | if artisttest == '': 54 | string = string.replace(('%' + key), 55 | process(files.get_metadata('ta'), 56 | strip_ntfs)) 57 | # print(string + ' ALBUM ARTIST NOT FOUND') 58 | else: 59 | string = string.replace(('%' + key), 60 | process(files.get_metadata(key), 61 | strip_ntfs)) 62 | # print(string + ' ALBUM ARTIST FOUND') 63 | else: 64 | string = string.replace(('%' + key), 65 | process(files.get_metadata(key), 66 | strip_ntfs)) 67 | return string 68 | 69 | 70 | # Process names and replace any undesired characters 71 | def process(string, strip_ntfs=False): 72 | """ Prevent / character to avoid creating folders """ 73 | string = string.replace('/', '_') # if present 74 | if strip_ntfs: 75 | string = ''.join(c for c in string if c not in '<>:"\\|?*') 76 | while string.endswith('.'): 77 | string = string[:-1] 78 | return string 79 | 80 | 81 | def results(prelist, damlist): 82 | """ Show the results of your preview run """ 83 | if not os.stat(prelist)[6] == 0: 84 | print('fileorganizer: open preview list') 85 | subprocess.Popen(['/usr/bin/xdg-open', prelist]) 86 | if not os.stat(damlist)[6] == 0: 87 | print('fileorganizer: open damaged file list') 88 | subprocess.Popen(['/usr/bin/xdg-open', damlist]) 89 | return 90 | 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Development Stop 2 | ================ 3 | 4 | Hi everyone, I took over this plugin many years ago and have since moved on to other methods of maintaining my library. If someone wants to fork it and take over i'm happy to let this go as i don't have the need for this plugin anymore. 5 | 6 | 7 | RHYTHMBOX FILEORGANIZER 8 | ======================= 9 | 10 | Please help with testing this new release! 11 | A lot of big changes have happened that need testing before i can be comfortable with a stable release. 12 | 13 | ------------------------------------- 14 | WARNING, ONGOING DEVELOPMENT VERSION. 15 | ------------------------------------- 16 | 17 | * Please be aware that for the moment this repo may have bugs 18 | that i haven't noticed in my testing. 19 | * I have tested all current features and they work as expected 20 | (But that isn't a promise it will be stable for you) 21 | 22 | Welcome to version 3.91-dev 23 | 24 | This update removes a lot of code that doesn't have any real purpose in the current rhythmbox. 25 | 26 | I have dropped dbops.py and simplified the database naming using urllib.parse. 27 | 28 | We no longer look for cover art as this has changed from older versions of rhythmbox. 29 | 30 | Instead of updating tags this feature is removed. 31 | 32 | So far in testing the changes are setting correct paths but the files are sometimes becoming 'missing' 33 | The files move and update but I think this may be due to my large library (180,000) 34 | and that testing has been done over sshfs as well as local files. 35 | 36 | 37 | 38 | 39 | 1.0 Install 40 | 2.0 Usage & Main Features 41 | 2.1 Other Features 42 | 3.0 Configuration and customisation 43 | 3.1 Compilation Support 44 | 3.2 Plugin Preferences Window 45 | 4.0 Change History 46 | 5.0 Contribute 47 | 6.0 Links 48 | 49 | 1.0 INSTALL 50 | ----------- 51 | 52 | To install from the terminal using make: 53 | make install 54 | 55 | To check the dependencies, then install using python: 56 | python3 ./install.py 57 | 58 | If you want to install manually, extract to the following directory: 59 | * $HOME/.local/share/rhythmbox/plugins/fileorganizer/ 60 | 61 | You can test python dependencies by running: 62 | python3 -c "import depends_test; depends_test.check()" 63 | 64 | Possible extra requirements are: 65 | * python-configparser (I have to confirm this but i think it's a default module in python 3.2+) 66 | * gir1.2-notify-0.7 (Debian name, GObject notify library) 67 | * dconf-editor (to make changes to the rhythmbox library settings) 68 | 69 | 70 | 2.0 USAGE & MAIN FEATURES 71 | ------------------------- 72 | 73 | This plugin is pretty simple but it has a few complicated features under the hood. 74 | 75 | Once the plugin is installed, simply enable it in Rhythmbox. A restart of rhythmbox will be required to detect the plugin if it was open when you installed. 76 | 77 | When the plugin is enabled, you will notice an option in the right-click menu of music items (like songs) that will read 'Organize selection'. Clicking this will organize the selected files following a defined structure (see 3. Configuration and customisation) for both folders and filenames. That's all there is to it. 78 | 79 | 80 | 2.1 OTHER FEATURES 81 | 82 | Intelligent duplicate backup: 83 | * When two songs have the same name, the plugin moves the file to a backup directory. 84 | * If you lose a file, you'll probably in a folder named 'backup' in the root of your music library. 85 | 86 | Move all non music files with your music: 87 | * When enabled, Fileorganizer will move files like text files and pictures with that music file. 88 | * This is great for keeping all files organised, not just music. 89 | 90 | 91 | Log file for all actions: 92 | * The log file is an invaluable tool to see what happens when running fileorganizer. 93 | * By default this file is hidden in your home folder: $HOME/.fileorganizer.log 94 | 95 | 96 | 3.0 CONFIGURATION AND CUSTOMISATION 97 | ----------------------------------- 98 | 99 | The output when running 'Organize Selection' is set from dconf-editor using default Rhythmbox settings: 100 | * org.gnome.rhythmbox.library/layout-filename (Is the filename for your output) 101 | * org.gnome.rhythmbox.library/layout-path (Is the folder path for your output) 102 | * org.gnome.rhythmbox.rhythmdb/locations (Is your library path) 103 | 104 | Using these, your final output becomes: 105 | * library + layout-path + layout-filename 106 | 107 | The Locations setting can actually be multiple locations, the first value is always taken by the plugin. 108 | 109 | The Variables for layout_path and layout_filename follow the same values as rhythmbox: 110 | * %at -- album title 111 | * %aa -- album artist (Album artist will use track artist if it does not exist) 112 | * %aA -- album artist (lowercase) 113 | * %as -- album artist sortname 114 | * %aS -- album artist sortname (lowercase) 115 | * %ay -- album release year 116 | * %an -- album disc number 117 | * %aN -- album disc number, zero padded 118 | * %ag -- album genre 119 | * %aG -- album genre (lowercase) 120 | * %tn -- track number (i.e 8) 121 | * %tN -- track number, zero padded (i.e 08) 122 | * %tt -- track title 123 | * %ta -- track artist 124 | * %tA -- track artist (lowercase) 125 | 126 | Variables not ported yet: 127 | * %ts -- track artist sortname 128 | * %tS -- track artist sortname (lowercase) 129 | 130 | 131 | 3.1 COMPILATION SUPPORT 132 | 133 | Fileorganizer will use the album artist tag which is a part of rhythmbox and replace the artist field. For example: 134 | * Path: /music/$artist/$year $album/$disc-$track - $title 135 | * Input: /music/new/spawn soundtrack/01 - filter & the crystal method - trip like i do.mp3 136 | * Set Album Artist to 'Various' in Rhythmbox. 137 | * Output: /music/Various/1997 Spawn/1-01 - Can't You (Trip Like I Do).mp3 138 | 139 | 140 | 3.2 PLUGIN PREFERENCES WINDOW 141 | 142 | The preferences window gives you the ability to switch features on or off. 143 | 144 | Preview Mode 145 | * If enabled, 'Organize Selection' will only check for changes and open a text report after completion. 146 | 147 | File/Folder Cleanup 148 | * If enabled, files within the same folder that aren't music files are moved as well 149 | 150 | Remove Empty Folders 151 | * If the source folder is empty after moving, delete the folder 152 | 153 | Log File: 154 | * Set the filename of the log file (the base path is your home folder) 155 | 156 | Strip NTFS Chars 157 | * Strip out characters that Windows can't handle. 158 | (NTFS actually supports more characters than Windows allows) 159 | 160 | 161 | 162 | 4.0 CHANGE HISTORY 163 | ------------------ 164 | 165 | 3.99*-dev-* 166 | * Removed tag update options and code 167 | * Removed cover art import, the naming/format has changed 168 | * Using urllib.parse to encode DB imports 169 | 170 | 3.*-dev-* 171 | * Added python script install.py to check all imports. 172 | (Also added uninstall.py) 173 | * Removed older v2.99 zip file 174 | * Removed INSTALL & UNINSTALL (these were just calls to make anyway) 175 | * Ongoing pylint/refactor changes. 176 | * Update config window to remove depreciated widgets. (requires GTK+ 3.0) 177 | * Move conf template into base plugin dir 178 | 179 | Update 2015/05/05: 180 | * added strip_ntfs option (Care of @sirfz) 181 | [https://github.com/lachlan-00/rb-fileorganizer/commit/d8cf611f969a1fc250e7348b4e53285d13f950f3] 182 | 183 | 3.2013.09.16: 184 | Currently running on RB 3.0 185 | * Tag Library python-eyed3 not available for python 3. 186 | 187 | 2.0.1-2 features include: 188 | Preview Mode 189 | * Files are not moved or changed in any way while in preview mode. 190 | * When completed up to two text files will open showing changes or possibly damaged files. 191 | * To enable preview mode, set enable it in the preferences window. 192 | Update Tags After Relocation 193 | * The plugin now uses python-eyeD3 for checking tag values. 194 | * After organising the selected files, fileorganizer will update the mp3 tags for you to 195 | 196 | 2.0 features include: 197 | * GTK3 Rhythmbox 3/GIT support 198 | * Moved settings from Gconf to Gsettings 199 | * Random bug fixes 200 | * New code base [1] 201 | 202 | 1.1 features include: 203 | * UI Implemented 204 | * Configuration File 205 | * Import cover art from the source folder to the RB cache if found. 206 | * Ability to disable file/folder cleanup and other features. 207 | 208 | 1.0.3-2 features include: 209 | * Fixes to backup support. 210 | * UTF-8 encoding support. 211 | * Fixed move folder contents with files. 212 | * Notification on completion using pynotify. 213 | * More code cleanup and additions. 214 | 215 | 1.0.3 features include: 216 | * File management of non music files. 217 | * A physical log file stored in the home folder. 218 | * Moved the backup folder to the root of the music library. 219 | * Compilation support using rhythmbox's album artist field. 220 | 221 | 1.0.2 features include: 222 | * Support for Rhythmbox > 0.13.1 223 | * Added $disc and $year support. 224 | 225 | 226 | 5.0 CONTRIBUTE 227 | -------------- 228 | 229 | To contribute, please refer to our github page [2] 230 | 231 | 232 | 6.0 LINKS 233 | --------- 234 | 235 | [1] http://code.launchpad.net/~lachlan-00/rb-fileorganizer/legacy 236 | [2] https://github.com/lachlan-00/rb-fileorganizer 237 | -------------------------------------------------------------------------------- /config.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | vertical 9 | 2 10 | 11 | 12 | True 13 | False 14 | center 15 | 7 16 | File Organizer Preferences 17 | 18 | 19 | 20 | 21 | 22 | 23 | False 24 | True 25 | 0 26 | 27 | 28 | 29 | 30 | True 31 | False 32 | vertical 33 | 5 34 | 35 | 36 | True 37 | False 38 | vertical 39 | 5 40 | 41 | 42 | Preview Mode 43 | True 44 | True 45 | False 46 | start 47 | right 48 | True 49 | 50 | 51 | False 52 | True 53 | 1 54 | 55 | 56 | 57 | 58 | False 59 | True 60 | 0 61 | 62 | 63 | 64 | 65 | True 66 | False 67 | vertical 68 | 5 69 | 70 | 71 | File/Folder Cleanup 72 | True 73 | True 74 | False 75 | start 76 | right 77 | True 78 | 79 | 80 | False 81 | True 82 | 1 83 | 84 | 85 | 86 | 87 | False 88 | True 89 | 1 90 | 91 | 92 | 93 | 94 | True 95 | False 96 | vertical 97 | 5 98 | 99 | 100 | Remove Empty Folders 101 | True 102 | True 103 | False 104 | start 105 | right 106 | True 107 | 108 | 109 | False 110 | True 111 | 1 112 | 113 | 114 | 115 | 116 | False 117 | True 118 | 2 119 | 120 | 121 | 122 | 123 | True 124 | False 125 | vertical 126 | 5 127 | 128 | 129 | Strip NTFS Chars 130 | True 131 | True 132 | False 133 | start 134 | right 135 | True 136 | 137 | 138 | False 139 | True 140 | 1 141 | 142 | 143 | 144 | 145 | False 146 | True 147 | 3 148 | 149 | 150 | 151 | 152 | True 153 | False 154 | 65 155 | 156 | 157 | Log File 158 | True 159 | True 160 | False 161 | right 162 | True 163 | 164 | 165 | False 166 | True 167 | 0 168 | 169 | 170 | 171 | 172 | True 173 | True 174 | 175 | 176 | 177 | True 178 | True 179 | 1 180 | 181 | 182 | 183 | 184 | False 185 | True 186 | 4 187 | 188 | 189 | 190 | 191 | False 192 | True 193 | 1 194 | 195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /fileorganizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Fileorganizer 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | Wolter Hellmund 8 | ----------------Licence---------------- 9 | Creative Commons - Attribution Share Alike v3.0 10 | 11 | """ 12 | 13 | import configparser 14 | import os 15 | import shutil 16 | import gi 17 | 18 | gi.require_version('Peas', '1.0') 19 | gi.require_version('PeasGtk', '1.0') 20 | gi.require_version('Notify', '0.7') 21 | gi.require_version('RB', '3.0') 22 | 23 | from gi.repository import GObject, Peas, PeasGtk, Gtk, Notify, Gio 24 | from gi.repository import RB 25 | 26 | import fileops 27 | import tools 28 | 29 | from configurator import FileorganizerConf 30 | 31 | PLUGIN_PATH = 'plugins/fileorganizer/' 32 | CONFIGFILE = 'fo.conf' 33 | CONFIGTEMPLATE = 'fo.conf.template' 34 | UIFILE = 'config.ui' 35 | C = "conf" 36 | 37 | 38 | class Fileorganizer(GObject.Object, Peas.Activatable, PeasGtk.Configurable): 39 | """ Main class that loads fileorganizer into Rhythmbox """ 40 | __gtype_name = 'fileorganizer' 41 | object = GObject.property(type=GObject.Object) 42 | _menu_names = ['browser-popup', 43 | 'playlist-popup'] 44 | 45 | def __init__(self, *args, **kwargs): 46 | GObject.Object.__init__(self) 47 | super(Fileorganizer, self).__init__(*args, **kwargs) 48 | self.configurator = FileorganizerConf() 49 | self.conf = configparser.RawConfigParser() 50 | self.configfile = RB.find_user_data_file(PLUGIN_PATH + CONFIGFILE) 51 | self.ui_file = RB.find_user_data_file(PLUGIN_PATH + UIFILE) 52 | self.shell = None 53 | self.rbdb = None 54 | self.action_group = None 55 | self.action = None 56 | self.source = None 57 | self.plugin_info = "fileorganizer" 58 | 59 | # Rhythmbox standard Activate method 60 | def do_activate(self): 61 | """ Activate the plugin """ 62 | print("activating Fileorganizer") 63 | shell = self.object 64 | self.shell = shell 65 | self.rbdb = shell.props.db 66 | self._check_configfile() 67 | self.menu_build(shell) 68 | 69 | # Rhythmbox standard Deactivate method 70 | def do_deactivate(self): 71 | """ Deactivate the plugin """ 72 | print("deactivating Fileorganizer") 73 | app = Gio.Application.get_default() 74 | for menu_name in Fileorganizer._menu_names: 75 | app.remove_plugin_menu_item(menu_name, 'selection-' + 'organize') 76 | self.action_group = None 77 | self.action = None 78 | # self.source.delete_thyself() 79 | self.source = None 80 | 81 | # FUNCTIONS 82 | # check if configfile is present, if not copy from template folder 83 | def _check_configfile(self): 84 | """ Copy the default config template or load existing config file """ 85 | if not os.path.isfile(self.configfile): 86 | template = RB.find_user_data_file(PLUGIN_PATH + CONFIGTEMPLATE) 87 | folder = os.path.split(self.configfile)[0] 88 | if not os.path.exists(folder): 89 | os.makedirs(folder) 90 | shutil.copyfile(template, self.configfile) 91 | 92 | # Build menu option 93 | def menu_build(self, shell): 94 | """ Add 'Organize Selection' to the Rhythmbox righ-click menu """ 95 | app = Gio.Application.get_default() 96 | 97 | # create action 98 | action = Gio.SimpleAction(name="organize-selection") 99 | action.connect("activate", self.organize_selection) 100 | app.add_action(action) 101 | 102 | # create menu item 103 | item = Gio.MenuItem() 104 | item.set_label("Organize Selection") 105 | item.set_detailed_action("app.organize-selection") 106 | 107 | # add plugin menu item 108 | # app.add_plugin_menu_item('browser-popup', "Organize Selection", item) 109 | for menu_name in Fileorganizer._menu_names: 110 | app.add_plugin_menu_item(menu_name, "Organize Selection", item) 111 | app.add_action(action) 112 | 113 | # Create the Configure window in the rhythmbox plugins menu 114 | def do_create_configure_widget(self): 115 | """ Load the glade UI for the config window """ 116 | build = Gtk.Builder() 117 | build.add_from_file(self.ui_file) 118 | self._check_configfile() 119 | self.conf.read(self.configfile) 120 | window = build.get_object("fileorganizer") 121 | build.get_object("log_path").set_text(self.conf.get(C, "log_path")) 122 | if self.conf.get(C, "log_enabled") == "True": 123 | build.get_object("logbutton").set_active(True) 124 | if self.conf.get(C, "cleanup_enabled") == "True": 125 | build.get_object("cleanupbutton").set_active(True) 126 | if self.conf.get(C, "cleanup_empty_folders") == "True": 127 | build.get_object("removebutton").set_active(True) 128 | if self.conf.get(C, "preview_mode") == "True": 129 | build.get_object("previewbutton").set_active(True) 130 | if self.conf.get(C, "strip_ntfs") == "True": 131 | build.get_object("ntfsbutton").set_active(True) 132 | 133 | build.get_object("logbutton").connect('clicked', lambda x: self.save_config(build)) 134 | build.get_object("log_path").connect('changed', lambda x: self.save_config(build)) 135 | build.get_object("cleanupbutton").connect('clicked', lambda x: self.save_config(build)) 136 | build.get_object("removebutton").connect('clicked', lambda x: self.save_config(build)) 137 | build.get_object("previewbutton").connect('clicked', lambda x: self.save_config(build)) 138 | build.get_object("ntfsbutton").connect('clicked', lambda x: self.save_config(build)) 139 | 140 | return window 141 | 142 | def save_config(self, builder): 143 | """ Save changes to the plugin config """ 144 | if builder.get_object("logbutton").get_active(): 145 | self.conf.set(C, "log_enabled", "True") 146 | else: 147 | self.conf.set(C, "log_enabled", "False") 148 | 149 | if builder.get_object("cleanupbutton").get_active(): 150 | self.conf.set(C, "cleanup_enabled", "True") 151 | else: 152 | self.conf.set(C, "cleanup_enabled", "False") 153 | 154 | if builder.get_object("removebutton").get_active(): 155 | self.conf.set(C, "cleanup_empty_folders", "True") 156 | else: 157 | self.conf.set(C, "cleanup_empty_folders", "False") 158 | if builder.get_object("previewbutton").get_active(): 159 | self.conf.set(C, "preview_mode", "True") 160 | else: 161 | self.conf.set(C, "preview_mode", "False") 162 | if builder.get_object("ntfsbutton").get_active(): 163 | self.conf.set(C, "strip_ntfs", "True") 164 | else: 165 | self.conf.set(C, "strip_ntfs", "False") 166 | self.conf.set(C, "log_path", 167 | builder.get_object("log_path").get_text()) 168 | datafile = open(self.configfile, "w") 169 | self.conf.write(datafile) 170 | datafile.close() 171 | 172 | # Organize selection 173 | def organize_selection(self, action, shell): 174 | """ get your current selection and run process_selection """ 175 | page = self.shell.props.selected_page 176 | if not hasattr(page, "get_entry_view"): 177 | return 178 | selected = page.get_entry_view() 179 | selection = selected.get_selected_entries() 180 | self.process_selection(selection) 181 | 182 | # Process selection: Run in Preview Mode or Normal Mode 183 | def process_selection(self, filelist): 184 | """ using your selection, run the preview or process from fileops """ 185 | self.conf.read(self.configfile) 186 | strip_ntfs = self.conf.get(C, "strip_ntfs") == "True" 187 | # Run in Preview Modelogops 188 | if self.conf.get(C, "preview_mode") == "True": 189 | if filelist: 190 | prelist = os.getenv('HOME') + '/.fileorganizer-preview.log' 191 | datafile = open(prelist, "w") 192 | datafile.close() 193 | damlist = os.getenv('HOME') + '/.fileorganizer-damaged.log' 194 | datafile = open(damlist, "w") 195 | datafile.close() 196 | for item in filelist: 197 | item = fileops.MusicFile(self, item, strip_ntfs=strip_ntfs) 198 | item.preview() 199 | Notify.init('Fileorganizer') 200 | title = 'Fileorganizer' 201 | note = 'Preview Has Completed' 202 | notification = Notify.Notification.new(title, note, None) 203 | Notify.Notification.show(notification) 204 | # Show Results of preview 205 | tools.results(prelist, damlist) 206 | else: 207 | # Run Normally 208 | self.organize(filelist, strip_ntfs) 209 | Notify.init('Fileorganizer') 210 | title = 'Fileorganizer' 211 | note = 'Your selection is organised' 212 | notification = Notify.Notification.new(title, note, None) 213 | Notify.Notification.show(notification) 214 | return 215 | 216 | # Organize array of files 217 | def organize(self, filelist, strip_ntfs=False): 218 | """ get fileops to move media files to the correct location """ 219 | if filelist: 220 | for item in filelist: 221 | item = fileops.MusicFile(self, item, strip_ntfs=strip_ntfs) 222 | item.relocate() 223 | return 224 | 225 | 226 | class PythonSource(RB.Source): 227 | """ Register with rhythmbox """ 228 | 229 | def __init__(self): 230 | RB.Source.__init__(self) 231 | GObject.type_register_dynamic(PythonSource) 232 | -------------------------------------------------------------------------------- /fileops.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Fileorganizer file operations 4 | 5 | ----------------Authors---------------- 6 | Lachlan de Waard 7 | Wolter Hellmund 8 | ----------------Licence---------------- 9 | Creative Commons - Attribution Share Alike v3.0 10 | 11 | """ 12 | 13 | import os 14 | import shutil 15 | import time 16 | import configparser 17 | import gi 18 | import urllib.parse 19 | 20 | gi.require_version('RB', '3.0') 21 | 22 | from gi.repository import RB 23 | 24 | import tools 25 | 26 | from logops import LogFile 27 | 28 | 29 | RB_METATYPES = ('at', 'aa', 'aA', 'as', 'aS', 'ay', 'an', 'aN', 'ag', 'aG', 30 | 'tn', 'tN', 'tt', 'ta', 'tA') 31 | RB_MEDIA_TYPES = ['.m4a', '.flac', '.ogg', '.mp2', '.mp3', '.wav', '.spx'] 32 | 33 | PROP = [RB.RhythmDBPropType.ALBUM, RB.RhythmDBPropType.ALBUM_ARTIST, 34 | RB.RhythmDBPropType.ALBUM_ARTIST_FOLDED, 35 | RB.RhythmDBPropType.ALBUM_ARTIST_SORTNAME, 36 | RB.RhythmDBPropType.ALBUM_ARTIST_SORTNAME_FOLDED, 37 | RB.RhythmDBPropType.YEAR, RB.RhythmDBPropType.DISC_NUMBER, 38 | RB.RhythmDBPropType.GENRE, RB.RhythmDBPropType.GENRE_FOLDED, 39 | RB.RhythmDBPropType.TRACK_NUMBER, RB.RhythmDBPropType.TITLE, 40 | RB.RhythmDBPropType.ARTIST, RB.RhythmDBPropType.ARTIST_FOLDED] 41 | 42 | IN = ' IN: ' 43 | OUT = ' OUT: ' 44 | INFO = ' ** INFO: ' 45 | ERROR = ' ** ERROR: ' 46 | CONFLICT = ' ** CONFLICT: ' 47 | NO_NEED = 'No need for file relocation' 48 | STILL_MEDIA = 'Directory still contains media; keeping:' 49 | FILE_EXISTS = 'File exists, directing to backup folder' 50 | POSSIBLE_DAMAGE = "Source file damaged or missing tag information.\n" 51 | DIR_REMOVED = 'Removing empty directory' 52 | UPDATING = 'Updating Database:' 53 | 54 | 55 | class MusicFile(object): 56 | """ Class that performs all the file operations """ 57 | 58 | def __init__(self, fileorganizer, db_entry=None, strip_ntfs=False): 59 | self.conf = configparser.RawConfigParser() 60 | conffile = (os.getenv('HOME') + '/.local/share/rhythmbox/' + 61 | 'plugins/fileorganizer/fo.conf') 62 | self.conf.read(conffile) 63 | self.rbfo = fileorganizer 64 | self.rbdb = self.rbfo.rbdb 65 | self.log = LogFile() 66 | # self.url = UrlData() 67 | self.strip_ntfs = strip_ntfs 68 | if db_entry: 69 | # Track and disc digits from gconf 70 | padded = '%s' % ('%0' + str(2) + '.d') 71 | single = '%s' % ('%0' + str(1) + '.d') 72 | self.metadata = { 73 | RB_METATYPES[0]: db_entry.get_string(PROP[0]), 74 | RB_METATYPES[1]: db_entry.get_string(PROP[1]), 75 | RB_METATYPES[2]: db_entry.get_string(PROP[2]), 76 | RB_METATYPES[3]: db_entry.get_string(PROP[3]), 77 | RB_METATYPES[4]: db_entry.get_string(PROP[4]), 78 | RB_METATYPES[5]: str(db_entry.get_ulong(PROP[5])), 79 | RB_METATYPES[6]: str(single % (db_entry.get_ulong(PROP[6]))), 80 | RB_METATYPES[7]: str(padded % (db_entry.get_ulong(PROP[6]))), 81 | RB_METATYPES[8]: db_entry.get_string(PROP[7]), 82 | RB_METATYPES[9]: db_entry.get_string(PROP[8]), 83 | RB_METATYPES[10]: str(single % (db_entry.get_ulong(PROP[9]))), 84 | RB_METATYPES[11]: str(padded % (db_entry.get_ulong(PROP[9]))), 85 | RB_METATYPES[12]: db_entry.get_string(PROP[10]), 86 | RB_METATYPES[13]: db_entry.get_string(PROP[11]), 87 | RB_METATYPES[14]: db_entry.get_string(PROP[12]) 88 | } 89 | self.location = db_entry.get_string(RB.RhythmDBPropType.LOCATION) 90 | self.entry = db_entry 91 | self.rbdb_rep = ('%28', '%29', '%2B', '%27', '%2C', '%3A', '%21', 92 | '%24', '%26', '%2A', '%2C', '%2D', '%2E', '%3D', 93 | '%40', '%5F', '%7E', '%C3%A8') 94 | self.rbdb_itm = ('(', ')', '+', "'", ',', ':', '!', 95 | '$', '&', '*', ',', '-', '.', '=', 96 | '@', '_', '~', 'è') 97 | 98 | def set_ascii(self, string): 99 | """ Change unicode codes back to ascii for RhythmDB 100 | RythmDB doesn't use a full URL for file path 101 | """ 102 | count = 0 103 | while count < len(self.rbdb_rep): 104 | string = string.replace(self.rbdb_rep[count], 105 | self.rbdb_itm[count]) 106 | count += 1 107 | 108 | return string 109 | 110 | # Returns metadata of the music file 111 | def get_metadata(self, key): 112 | """ Return metadata of current file """ 113 | for datum in self.metadata: 114 | if key == datum: 115 | return self.metadata[datum] 116 | 117 | # Non media clean up 118 | def file_cleanup(self, source, destin): 119 | """ Remove empty folders and move non-music files with selection """ 120 | cleanup_enabled = self.conf.get('conf', 'cleanup_enabled') 121 | remove_folders = self.conf.get('conf', 'cleanup_empty_folders') 122 | if cleanup_enabled == 'True': 123 | sourcedir = os.path.dirname(source) 124 | destindir = os.path.dirname(destin) 125 | foundmedia = False 126 | # Remove empty folders, if any 127 | if os.path.isdir(sourcedir): 128 | if not os.listdir(sourcedir) == []: 129 | for files in os.listdir(sourcedir): 130 | filelist = files[(files.rfind('.')):] 131 | if filelist in RB_MEDIA_TYPES or os.path.isdir( 132 | sourcedir + '/' + files): 133 | foundmedia = True 134 | elif not destindir == sourcedir: 135 | mvdest = destindir + '/' + os.path.basename(files) 136 | mvsrc = sourcedir + '/' + os.path.basename(files) 137 | try: 138 | shutil.move(mvsrc, mvdest) 139 | except FileNotFoundError: 140 | self.log.log_processing(ERROR + 'Moving ' + 141 | files) 142 | except PermissionError: 143 | self.log.log_processing(ERROR + 'Moving ' + 144 | files) 145 | except Exception as e: 146 | self.log.log_processing(ERROR + 'Moving ' + 147 | files) 148 | print(e) 149 | finally: 150 | self.log.log_processing(INFO + 'Moved') 151 | self.log.log_processing(' ' + mvdest) 152 | if foundmedia: 153 | self.log.log_processing(INFO + STILL_MEDIA) 154 | # remove empty folders after moving additional files 155 | if os.listdir(sourcedir) == [] and remove_folders == 'True': 156 | currentdir = sourcedir 157 | self.log.log_processing(INFO + DIR_REMOVED) 158 | while not os.listdir(currentdir): 159 | self.log.log_processing(' ' + currentdir) 160 | os.rmdir(currentdir) 161 | currentdir = os.path.split(currentdir)[0] 162 | 163 | # Get Source and Destination separately so preview can use the same code 164 | def get_locations(self, inputstring): 165 | """ Get file path for other file operations """ 166 | # Get source for comparison 167 | source = self.location.replace('file:///', '/') 168 | if inputstring == 'source': 169 | return urllib.parse.unquote(source) 170 | # Set Destination Directory 171 | targetdir = '/' + self.rbfo.configurator.get_val('layout-path') 172 | targetdir = tools.data_filler(self, targetdir, 173 | strip_ntfs=self.strip_ntfs) 174 | targetloc = self.rbfo.configurator.get_val('locations')[0] 175 | targetpath = targetloc.replace('file:///', '/') 176 | targetdir = tools.folderize(targetpath, targetdir) 177 | # Set Destination Filename 178 | targetname = self.rbfo.configurator.get_val('layout-filename') 179 | targetname = tools.data_filler(self, targetname, 180 | strip_ntfs=self.strip_ntfs) 181 | targetname += os.path.splitext(self.location)[1] 182 | # Join destination 183 | if inputstring == 'destin': 184 | return urllib.parse.unquote((os.path.join(targetdir, targetname))) 185 | return 186 | 187 | def preview(self): 188 | """ Running in preview mode does not change files in any way """ 189 | print('preview') 190 | previewlist = os.getenv('HOME') + '/.fileorganizer-preview.log' 191 | damagedlist = os.getenv('HOME') + '/.fileorganizer-damaged.log' 192 | source = self.get_locations('source') 193 | destin = urllib.parse.unquote(self.get_locations('destin')) 194 | if not source == destin: 195 | # Write to preview list 196 | logfile = open(previewlist, "a") 197 | logfile.write("Change Found:\n" + source + "\n") 198 | logfile.write(destin + "\n\n") 199 | logfile.close() 200 | 201 | # Moves the file to a specific location with a specific name 202 | def relocate(self): 203 | """Performs the actual moving. 204 | -Move file to correct place 205 | -Update file location in RB database. 206 | """ 207 | source = self.get_locations('source') 208 | destin = urllib.parse.unquote(self.get_locations('destin')) 209 | # Begin Log File 210 | tmptime = time.strftime("%I:%M:%S %p", time.localtime()) 211 | logheader = '%ta - %at - ' 212 | logheader = (tools.data_filler(self, logheader, 213 | strip_ntfs=self.strip_ntfs) + tmptime) 214 | # self.log = LogFile() 215 | self.log.log_processing(logheader) 216 | self.log.log_processing((IN + source)) 217 | 218 | # Relocate, if necessary 219 | if source == destin: 220 | print('No need for file relocation') 221 | self.log.log_processing(INFO + NO_NEED) 222 | else: 223 | if os.path.isfile(destin): 224 | # Copy the existing file to a backup dir 225 | tmpdir = (self.rbfo.configurator.get_val('locations'))[0].replace('file:///', '/') 226 | tmpdir = urllib.parse.unquote(tmpdir) 227 | backupdir = tools.folderize(tmpdir, 'backup/') 228 | backup = os.path.join(backupdir, os.path.basename(destin)) 229 | if os.path.isfile(backup): 230 | counter = 0 231 | backuptest = backup 232 | while os.path.isfile(backup): 233 | backup = backuptest 234 | counter += 1 235 | backup = (backup[:(backup.rfind('.'))] + str(counter) + 236 | backup[(backup.rfind('.')):]) 237 | try: 238 | os.makedirs(os.path.dirname(backupdir)) 239 | except OSError: 240 | pass 241 | try: 242 | shutil.move(source, backup) 243 | self.log.log_processing(CONFLICT + FILE_EXISTS) 244 | self.log.log_processing(OUT + backup) 245 | except FileNotFoundError: 246 | # we found a duplicate in the DB 247 | pass 248 | destin = backup 249 | else: 250 | # Move the file to desired destination 251 | shutil.move(source, destin) 252 | self.log.log_processing(OUT + destin) 253 | 254 | # Update Rhythmbox database 255 | self.location = urllib.parse.quote(destin) 256 | self.location = ('file://' + self.location) 257 | self.location = self.set_ascii(self.location) 258 | print('Relocating file \n%s to\n%s' % (source, destin)) 259 | self.log.log_processing(INFO + UPDATING) 260 | print(self.entry.get_string(RB.RhythmDBPropType.LOCATION)) 261 | print(self.location) 262 | self.log.log_processing(IN + self.entry.get_string(RB.RhythmDBPropType.LOCATION)) 263 | self.log.log_processing(OUT + self.location) 264 | # Make the change 265 | self.rbdb.entry_set(self.entry, 266 | RB.RhythmDBPropType.LOCATION, 267 | self.location) 268 | # Non media clean up 269 | self.file_cleanup(source, destin) 270 | self.log.log_processing('') 271 | --------------------------------------------------------------------------------