├── .gitignore ├── README.md ├── iTunesWatchFolder.py ├── musicTools.py └── rct.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | venv/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mediaTools 2 | A Collection of Scripts and Tools for organizing my media stuff 3 | 4 | * iTunesWatchFolder - A Helper Script to Sync a Folder with iTunes 5 | * musicTools - A Collection of Functions to validate a music-ibrary 6 | * rct.sh - Traverse a Directory and look for extracted rar files and delete them. 7 | -------------------------------------------------------------------------------- /iTunesWatchFolder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """ 4 | iTunesWatchFolder Script 5 | - Synchronize iTunes Library <--> Folder 6 | 7 | Written by u/RoboYoshi 8 | """ 9 | 10 | import sys, time, itertools, threading, queue 11 | import os, subprocess, re, plistlib, datetime, shutil 12 | from urllib.parse import quote, unquote, urlparse 13 | from pprint import pprint as pp 14 | 15 | debug = False 16 | backup = True 17 | addnew = True 18 | rmdead = True 19 | 20 | # If used in Application Context 21 | if(debug): print("[Debug]\t sys.argv = ", sys.argv) 22 | if(len(sys.argv))>1: 23 | musicFolder = sys.argv[1] 24 | else: 25 | print("[Warn]\t No Library given to sync with.") 26 | sys.exit(1) 27 | 28 | allowedExtensions=('mp3', 'm4a', 'm4b') # can be extended to your liking 29 | 30 | def loadingAnimation(): 31 | for c in itertools.cycle(['|', '/', '-', '\\']): 32 | if done: 33 | break 34 | sys.stdout.write('\r' + c) 35 | sys.stdout.flush() 36 | time.sleep(0.1) 37 | sys.stdout.write('\rDone!') 38 | 39 | def mkdir_p(path): 40 | try: 41 | os.makedirs(path) 42 | except OSError as exc: # Python >2.5 43 | if exc.errno == errno.EEXIST and os.path.isdir(path): 44 | pass 45 | else: 46 | raise 47 | 48 | 49 | def getiTunesXMLPath(): 50 | """ 51 | Read iTunes XML Path from user defaults 52 | :return string: xmlPath as String 53 | """ 54 | # The user needs to enable 'Share iTunes Library XML with other applications' under Settings -> Advanced 55 | xmlPattern = ".*(file:.*xml).*" 56 | xmlProcess = subprocess.Popen(['defaults', 'read', 'com.apple.iApps', 'iTunesRecentDatabases'], stdout=subprocess.PIPE) 57 | xmlOutput = str(xmlProcess.stdout.read()) 58 | xmlMatch = re.match(xmlPattern, xmlOutput) 59 | if(xmlMatch): 60 | xmlPath = xmlMatch.group(1).replace("file://","") 61 | xmlPath = unquote(xmlPath) 62 | if(debug): print("[Debug]\t xmlPath = %s" % xmlPath) 63 | return xmlPath 64 | else: 65 | print("[Error]\t Could not get XML Path from defaults. Are you sharing your XML in iTunes -> Settings -> Advanced?") 66 | return 1 67 | 68 | def backupLibraryDB(xmlPath): 69 | print("[Info]\t Create Backup of iTunes Library DB.") 70 | backupFiles = ['iTunes Library Extras.itdb', 'iTunes Library Genius.itdb', 'iTunes Library.itl', 'iTunes Library.xml', 'iTunes Music Library.xml'] 71 | libPath = os.path.dirname(xmlPath) 72 | timestamp = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S') 73 | backupFolder = libPath + "/Backups/" + timestamp + "/" 74 | mkdir_p(backupFolder); 75 | for libFile in backupFiles: 76 | libFilePath = os.path.join(libPath, libFile) 77 | bakFilePath = os.path.join(backupFolder, libFile) 78 | try: 79 | shutil.copy(libFilePath, bakFilePath) 80 | except: 81 | if(debug): print("[Debug]\t libFile Missing: %s" % libFile) 82 | pass 83 | if(debug): print("[Debug]\t Created a Backup of your iTunes Library in %s" % backupFolder) 84 | 85 | 86 | def getTracksFromiTunesXML(xmlPath): 87 | """ 88 | Extract Track Paths from iTunes XML 89 | :param xmlPath: Path to your iTunes Library XML (Plist) file 90 | :return list: List with all Tracks 91 | """ 92 | # Thanks to https://github.com/liamks/libpytunes for some pointers 93 | print("[Info]\t Fetching all Tracks from iTunes Library..") 94 | libTracks = [] # RAW POSIX PATH PLS 95 | library = plistlib.readPlist(xmlPath) 96 | for trackid, attributes in library['Tracks'].items(): 97 | if attributes.get('Location'): 98 | location = unquote(urlparse(attributes.get('Location')).path) 99 | if(debug): print("[Debug]\t Track Path in iTunes = %s" % location) 100 | libTracks.append(location) 101 | print("[Info]\t Tracks in iTunes = %i" % len(libTracks)) 102 | q_libTracks.put(libTracks) 103 | return libTracks 104 | 105 | def getTracksFromFolder(dirPath, ext=('mp3', 'm4a')): 106 | print("[Info]\t Search for Files in Folder with allowed Extensions..") 107 | dirTracks = [] # RAW POSIX PATH PLS 108 | count = 0 109 | for root, dirs, files in os.walk(dirPath): 110 | for name in files: 111 | if(name.lower().endswith(ext)): 112 | track = os.path.join(root, name) 113 | dirTracks.append(track) 114 | count+=1 115 | if count % 500 == 0: 116 | if(debug): print("[Debug]: Found %s Tracks.." % count) 117 | print("[Info]\t Tracks in Folder = %i" % len(dirTracks)) 118 | q_dirTracks.put(dirTracks) 119 | return dirTracks 120 | 121 | def filterTracksForImport(dirTracks, libTracks): 122 | """ 123 | Match 2 Lists and check what Files are not in iTunes 124 | :param dirTracks, libTracks: Python Lists - Folder and iTunes 125 | :return list: sorted list with all missing tracks 126 | """ 127 | newTracks = list(set(dirTracks) - set(libTracks)) 128 | if(debug): print("[Debug]\t New Tracks in Folder = %i" % len(newTracks)) 129 | return sorted(newTracks) 130 | 131 | def importTracksToiTunes(newTracks): 132 | print("[Info]\t Adding new Tracks to iTunes.") 133 | """ 134 | Import a Bunch of Tracks into iTunes 135 | :param newTracks: Python List with all new Tracks 136 | :return int: 0 137 | """ 138 | for track in newTracks: 139 | if(debug): print("[Debug]\t TrackPath = %s" % track) 140 | # Script Notes: Unicode is needed for files with special characters. 141 | script = ''' 142 | on run {input} 143 | set trackFile to POSIX file input as Unicode text 144 | tell app "iTunes" 145 | add trackFile to playlist 1 146 | end tell 147 | end run 148 | ''' 149 | args = [track] 150 | p = subprocess.Popen(['/usr/bin/osascript', '-'] + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 151 | stdout, stderr = p.communicate(script.encode('UTF-8')) 152 | if(debug): 153 | if(stderr!= b''): print("[Debug]\t AppleScript Error: %s" % stderr) 154 | return 0 155 | 156 | def removeDeadTracksFromiTunes(): 157 | print("[Info]\t Removing Dead Tracks from iTunes") 158 | # Script thankfully taken from https://apple.stackexchange.com/a/52860/71498 159 | script = ''' 160 | tell application "iTunes" 161 | repeat with t in (get file tracks of library playlist 1) 162 | if location of t is missing value then delete t 163 | end repeat 164 | end tell 165 | ''' 166 | p = subprocess.Popen(['/usr/bin/osascript', '-'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 167 | stdout, stderr = p.communicate(script.encode('UTF-8')) 168 | if(debug): 169 | if(stderr!= b''): print("[Debug]\t AppleScript Error: %s" % stderr) 170 | return 0 171 | 172 | 173 | # Get iTunes XML File 174 | xmlPath = getiTunesXMLPath() 175 | 176 | # Backup iTunes Library Files 177 | if(backup): backupLibraryDB(xmlPath) 178 | 179 | # Prepare Queues to store values 180 | q_libTracks = queue.Queue() 181 | q_dirTracks = queue.Queue() 182 | 183 | # Declare Threads 184 | t_libTracks = threading.Thread(target=getTracksFromiTunesXML, args=(xmlPath,)) 185 | t_dirTracks = threading.Thread(target=getTracksFromFolder, args=(musicFolder,), kwargs={'ext' : allowedExtensions}) 186 | 187 | done = False 188 | t_loading = threading.Thread(target=loadingAnimation) 189 | t_loading.start() 190 | 191 | # Start both Threads 192 | t_libTracks.start() 193 | t_dirTracks.start() 194 | 195 | # Wait until both are finished 196 | t_libTracks.join() 197 | t_dirTracks.join() 198 | 199 | done = True 200 | # Get Arrays from Thread-Queues 201 | libTracks = q_libTracks.get() 202 | dirTracks = q_dirTracks.get() 203 | 204 | # Diff Arrays and only keep Tracks not already in Library 205 | newTracks = filterTracksForImport(dirTracks, libTracks) 206 | 207 | if(len(newTracks) != 0): 208 | # Import Tracks into iTunes with AppleScript 209 | if(addnew): 210 | done = False 211 | t_loading = threading.Thread(target=loadingAnimation) 212 | t_loading.start() 213 | importTracksToiTunes(newTracks) 214 | done = True 215 | else: print("[Info]\t No New Tracks.") 216 | 217 | # Remove Dead Tracks from iTunes 218 | if(rmdead): removeDeadTracksFromiTunes() 219 | 220 | # EOF -------------------------------------------------------------------------------- /musicTools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os,re, platform 5 | from pprint import pprint as pp 6 | 7 | # Metadata IO 8 | # from mutagen.flac import FLAC 9 | 10 | # The Selfhosted Music-Toolbox 11 | 12 | # + ------------------------------------------------------------------- 13 | # | Base Directory to the root directory of the music 14 | # | 15 | # | audio/ 16 | # | artists/ 17 | # | 18 | # + ------------------------------------------------------------------- 19 | if platform.system() == 'Darwin': 20 | AUDIO_LIB='/Volumes/rxd01/audio/library' 21 | else: 22 | AUDIO_LIB='/mnt/rxd01/audio/library' 23 | AUDIO_DIR_ARTISTS = AUDIO_LIB + '/artists' 24 | 25 | # + ------------------------------------------------------------------- 26 | # | Excluded Files and Directories 27 | # | 28 | # + ------------------------------------------------------------------- 29 | EXCLUDES = ['.DS_Store', 'Artwork'] 30 | 31 | # + ------------------------------------------------------------------- 32 | # | Album Base Name: Artist - Year - Album (Edition) 33 | # | 34 | # | Examples: 35 | # | The Wombats - 2011 - This Modern Glitch (Australian Tour Edition) 36 | # | The Wombats - 2015 - Glitterbug 37 | # | 38 | # + ------------------------------------------------------------------- 39 | PATTERN_ALBUM_BASE = '^(.*) - ([1-2]\d{3}) - (.*)' 40 | 41 | # + ------------------------------------------------------------------- 42 | # | Album Metadata: [SOURCE - FORMAT - QUALITY] 43 | # | 44 | # | Examples: 45 | # | [CD - FLAC - Lossless] 46 | # | [GAME - MP3 - 320] 47 | # | [WEB - FLAC - Lossless] 48 | # | [VINYL - FLAC - 16-44] 49 | # | 50 | # + ------------------------------------------------------------------- 51 | PATTERN_ALBUM_FORMATS = '(WAVE|FLAC|ALAC|MP3|AAC)' 52 | PATTERN_ALBUM_SOURCES = '(CD|GAME|WEB|TAPE|VINYL)' 53 | PATTERN_ALBUM_QUALITY = '(Lossless|24-48|16-44|320|224|192|128|V0|V1)' 54 | PATTERN_ALBUM_META = '( \[' + PATTERN_ALBUM_SOURCES + ' - ' + PATTERN_ALBUM_FORMATS + ' - ' + PATTERN_ALBUM_QUALITY + '\])' 55 | 56 | # + ------------------------------------------------------------------- 57 | # | Album Identifier: {IDENTIFIER} 58 | # | A String that belongs to this particular release 59 | # | e.g. Barcode, CatalogueID, Label+Id, ... 60 | # | 61 | # | Examples: 62 | # | {3716232} 63 | # | {US, 509992 65787 2 9} 64 | # | 65 | # | Mainly used with CDs/Vinyl and therefore optional 66 | # + ------------------------------------------------------------------- 67 | PATTERN_ALBUM_CATALOG = '( \{.*\})?' 68 | 69 | # + ------------------------------------------------------------------- 70 | # | File Naming 71 | # | 72 | # | Example: 73 | # | 01 Life in Technicolor II.flac 74 | # | 75 | # + ------------------------------------------------------------------- 76 | PATTERN_AFILE_TRACKNAME = '^(\d{1,2} (.*).)' 77 | PATTERN_AFILE_EXTENSION = '\.(mp3|m4a|aac|flac|alac|wav)' 78 | 79 | 80 | # + ------------------------------------------------------------------- 81 | # | Main Patterns: Album / File 82 | # | 83 | # | Bundle the defined patterns to use them in the validation 84 | # + ------------------------------------------------------------------- 85 | PATTERN_ALBUM = PATTERN_ALBUM_BASE + PATTERN_ALBUM_META + PATTERN_ALBUM_CATALOG 86 | PATTERN_AFILE = PATTERN_AFILE_TRACKNAME + PATTERN_AFILE_EXTENSION 87 | 88 | # + ------------------------------------------------------------------- 89 | # | Additional Patterns 90 | # | 91 | # | Album MultiDisc: CD1,CD2 / CD01,CD02,.. / Disc01, Disc02 / ... 92 | # + ------------------------------------------------------------------- 93 | PATTERN_ALBUM_MULTIDISC = '^((CD|Disc) ?[0-9]+)$' 94 | 95 | 96 | # Terminal Color Codes 97 | CRED = '\033[91m' 98 | CGRE = '\033[92m' 99 | CEND = '\033[0m' 100 | 101 | # Counters 102 | ARTISTS_TOTAL = 0 103 | ARTISTS_RIGHT = 0 104 | ALBUMS_TOTAL = 0 105 | ALBUMS_RIGHT = 0 106 | 107 | # + ------------------------------------------------------------------- 108 | # | Artist Folder Contents: 109 | # | 110 | # | artist.ini <-- Contains Metadata / IDs 111 | # | artist.(jpg|png) <-- Main Picture for Galleries etc. 112 | # | <-- All Albums defined by pattern 113 | # | 114 | # + ------------------------------------------------------------------- 115 | def validate_artist(artistPath): 116 | root = os.listdir(artistPath) 117 | if root == []: 118 | print("! - Artist is empty") 119 | return False 120 | return True 121 | 122 | # + ------------------------------------------------------------------- 123 | # | Album Validation 124 | # | Single Disc Album: 125 | # | TRACKNUMBER - TRACKTITLE.EXT 126 | # | Cover.jpg 127 | # | Scans/Artwork / Front.jpg, Back.jpg 128 | # | ALBUMARTIST - ALBUM.cue 129 | # | ALBUMARTIST - ALBUM.log 130 | # --- 131 | # | Multi-Disc Album: 132 | # | CD1 / TRACKNUMBER - TRACKTITLE.EXT 133 | # | CD1 / Cover.jpeg 134 | # | CD1 / Scans|Artwork / ... 135 | # | CD1 / ALBUMARTIST - ALBUM.cue 136 | # | 137 | # + ------------------------------------------------------------------- 138 | def validate_album_contents(albumPath): 139 | root = os.listdir(albumPath) 140 | if root == []: 141 | # album is empty 142 | return False 143 | discs = [re.match(PATTERN_ALBUM_MULTIDISC, element) for element in root] 144 | count = len([element for element in discs if element is not None]) 145 | if(count > 1): 146 | for element in root: 147 | if (re.match(PATTERN_ALBUM_MULTIDISC, element)): 148 | validate_album_contents(os.path.join(albumPath, element)) 149 | else: 150 | # TODO: Validate disc contents 151 | return True 152 | 153 | # + ------------------------------------------------------------------- 154 | # | Validate Album Name 155 | # | 156 | # | Matches encountered folders that are expected to be albums 157 | # | against the defined pattern. 158 | # + ------------------------------------------------------------------- 159 | def validate_album_name(albumFolder): 160 | return re.match(PATTERN_ALBUM, albumFolder) 161 | 162 | # + ------------------------------------------------------------------- 163 | # | Main Function - Script Entrypoint 164 | # | 165 | # + ------------------------------------------------------------------- 166 | def validate_artist_section(sectionPath): 167 | global ARTISTS_TOTAL, ARTISTS_RIGHT, ALBUMS_TOTAL, ALBUMS_RIGHT 168 | for artist in sorted(os.listdir(sectionPath)): 169 | ARTISTS_TOTAL += 1 170 | artistPath = os.path.join(sectionPath, artist) 171 | good=[] 172 | bad=[] 173 | for album in sorted(os.listdir(artistPath)): 174 | albumPath = os.path.join(artistPath, album) 175 | if album in EXCLUDES: 176 | continue 177 | ALBUMS_TOTAL += 1 178 | content_ok = validate_album_contents(albumPath) 179 | name_ok = validate_album_name(album) 180 | if (name_ok): 181 | good.append(album) 182 | ALBUMS_RIGHT += 1 183 | else: 184 | bad.append(album) 185 | if len(bad) == 0: 186 | print(CGRE, artist, '✔︎', CEND) 187 | ARTISTS_RIGHT += 1 188 | else: 189 | print(CRED, artist, '✘', CEND) 190 | print_statistics() 191 | 192 | def print_statistics(): 193 | print("Artists Total: " + str(ARTISTS_TOTAL)) 194 | print("Artists Right: " + str(ARTISTS_RIGHT)) 195 | print("Albums Total: " + str(ALBUMS_TOTAL)) 196 | print("Albums Right: " + str(ALBUMS_RIGHT)) 197 | 198 | # + ------------------------------------------------------------------- 199 | # | Main Function - Script Entrypoint 200 | # | 201 | # + ------------------------------------------------------------------- 202 | if __name__ == "__main__": 203 | validate_artist_section(AUDIO_DIR_ARTISTS) 204 | 205 | # EOF 206 | -------------------------------------------------------------------------------- /rct.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # + Rar Cleanup Traversal (RCT) 3 | # + ------------------------------------------------------------- + 4 | # | Walks through directory structure and attempts to find all 5 | # | rar files. If a rar file is found, it checks if it has 6 | # | already been extraced or not. If so, it deletes the files 7 | # | in the currently traversed directory. 8 | # | --- 9 | # | Also see https://github.com/arfoll/unrarall. 10 | # | My script is just meant to be a small extension of that. 11 | # + ------------------------------------------------------------- + 12 | 13 | # Check if ${1} is a non-empty string: 14 | if [ "${1}" == "" ]; then 15 | echo "No argument given, aborting." 16 | exit 1 17 | fi 18 | 19 | # Check if ${1} is a valid directory: 20 | if [ ! -d "${1}" ]; then 21 | echo "${1} is not a valid directory, aborting." 22 | exit 1 23 | fi 24 | 25 | # Check if unrar is installed: 26 | if [ ! $(command -v unrar) ]; then 27 | echo "Missing unrar command, aborting" 28 | exit 1 29 | fi 30 | 31 | 32 | # Set Verbosity (0 = True/On, 1 = False/Off) 33 | RCT_VERBOSE=0 34 | 35 | # DEFINE FUNCTIONS 36 | 37 | # RarCleanupTraversal (rct) 38 | # Walks Through all Directory in $1 (input) and checks for extraced rar files. 39 | # If the file is extracted, it deletes all rar files it can find. 40 | function rct() { 41 | for file in "${1}"/* 42 | do 43 | # If: Not a directory or a Symlink 44 | if [ ! -d "${file}" -o -L "${file}" ] ; then 45 | # check if it's a rar file: 46 | if [[ "${file}" =~ .*.rar$ ]]; then 47 | unrar_compare_extracted_fsize "${file}" 48 | # Check if last command succeeded => OK to proceed 49 | if [ ${?} -eq 0 ]; then 50 | # delete all archive files 51 | _CURRENT_DIR="${1}" 52 | # extract filename-base from the full file path 53 | _CURRENT_FILE=$(basename -- "${file%.*}") 54 | [ RCT_VERBOSE ] && echo "Delete archive files based on ${_CURRENT_FILE}" 55 | find "${_CURRENT_DIR}" -maxdepth 1 -type f -regextype posix-egrep -iregex ".*/${_CURRENT_FILE}"'\.(sfv|[0-9]+|[r-z][0-9]+|rar|part[0-9]+.rar)$' -exec rm -f '{}' \; 56 | fi 57 | fi 58 | else 59 | # Do not Enter Directory, if it's a symlink 60 | if [ ! -L $"{file}" ]; then 61 | [ RCT_VERBOSE ] && echo "Traversing: ${file}/" 62 | rct "${file}" 63 | fi 64 | fi 65 | done 66 | } 67 | 68 | # --------------------------------------------------------------- # 69 | # Make sure that all files in the archive are extracted correctly 70 | # by comparing the filesize inside the archive with the extracted 71 | # file in the directory. 72 | # --------------------------------------------------------------- # 73 | # TODO: Test with nested files 74 | # --------------------------------------------------------------- # 75 | function unrar_compare_extracted_fsize(){ 76 | # Define RarFile 77 | rarfile="${1}" 78 | # Extract all Filenames from rarfile 79 | files_in_rar=$(unrar l "${rarfile}" | grep '\.\..*' | awk '{ print $5 }') 80 | # Extract Size+Filenames from rarfile 81 | files_in_rar_with_size=$(unrar l "${rarfile}" | grep '\.\..*' | awk '{ print $2, $5 }') 82 | # Define Variable for Success and set it to true by default 83 | # If any file is not found or the sizes do not match, 84 | # it should be extracted again. 85 | ALL_OK=0 86 | # Iterate over filenames 87 | while read -r fname; do 88 | # check if a file exists 89 | if [ -f "${fname}" ]; then 90 | # check if filesize matches with the one in the rarfile 91 | fsize=$(stat --printf="%s" ${fname}) 92 | rsize=$(echo "${files_in_rar_with_size}" | grep "${fname}" | awk '{ print $1 }') 93 | if [ ${fsize} -eq ${rsize} ]; then 94 | [ RCT_VERBOSE ] && echo "Extracted-File-Size of ${fname} is equal to Rared-File-Size [ ${fsize} / ${rsize} ]" 95 | else 96 | [ RCT_VERBOSE ] && echo "File Sizes do not match!" 97 | ALL_OK=1 98 | fi 99 | else 100 | [ RCT_VERBOSE ] && echo "Files are not extracted!" 101 | ALL_OK=1 102 | fi 103 | done <<< "${files_in_rar}" 104 | # Output result 105 | if [[ ALL_OK -eq 0 ]]; then 106 | [ RCT_VERBOSE ] && echo "OK $(basename "${1}")" 107 | return 0 108 | else 109 | [ RCT_VERBOSE ] && echo "ERROR $(basename "${1}")" 110 | return 1 111 | fi 112 | } 113 | 114 | 115 | # EXECUTE! 116 | rct "${1}" 117 | --------------------------------------------------------------------------------