2019-05-04 17:55:32 +00:00
#!/usr/bin/env python3
2020-10-16 06:59:39 +00:00
# BCAO - BandCamp Automatic Organiser
# copyright 2018-2019 @LynnearSoftware@fedi.lynnesbian.space
# Licensed under the GPLv3: https://www.gnu.org/licenses/gpl-3.0.html#content
# input: a .zip from bandcamp
# output: it organises it, adds cover art, puts it in the right place...
2019-05-04 17:55:32 +00:00
2020-10-16 08:35:49 +00:00
# TODO: this is written like a hacky bash script and i hate it
2019-05-04 17:55:32 +00:00
import subprocess , argparse , sys , os , re , base64
2020-10-16 08:25:02 +00:00
def log ( message : str , importance : int = 0 ) :
if not args . quiet or importance > 0 :
print ( message )
def die ( message : str , code : int = 1 ) :
log ( message )
exit ( code )
2019-05-04 17:55:32 +00:00
try :
from mutagen . oggvorbis import OggVorbis
from mutagen . mp3 import MP3
from mutagen . flac import FLAC
from mutagen . flac import Picture
from mutagen . aac import AAC
except ImportError :
2020-10-16 08:35:49 +00:00
# TODO: requirements.txt
2020-10-16 08:25:02 +00:00
die ( " Please install python3-mutagen (pip install mutagen) " )
2019-05-04 17:55:32 +00:00
try :
2020-10-16 08:35:49 +00:00
# TODO: see if there's a decent zip library for python
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " unzip " , " --help " ] )
except :
2020-10-16 08:25:02 +00:00
die ( " Please install unzip (apt install unzip) " )
2019-05-04 17:55:32 +00:00
try :
2020-10-16 08:35:49 +00:00
# TODO: don't use which ffs
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " which " , " convert " ] )
subprocess . check_output ( [ " which " , " identify " ] )
except :
2020-10-16 08:25:02 +00:00
die ( " Please install imagemagick, and ensure convert and identify are in your $PATH (apt install imagemagick " )
2019-05-04 17:55:32 +00:00
2020-10-16 08:25:02 +00:00
parser = argparse . ArgumentParser ( description = " Extracts the given zip file downloaded from Bandcamp and organises it. " )
2019-05-04 17:55:32 +00:00
parser . add_argument ( ' zip ' , help = ' The zip file to use ' )
2020-10-16 06:59:39 +00:00
parser . add_argument ( ' -d ' , ' --destination ' , dest = ' destination ' , default = ' /home/lynne/Music/Music/ ' , help = " The directory to organise the music into. Default: /home/lynne/Music/Music/ " )
2020-10-16 08:25:02 +00:00
parser . add_argument ( ' -q ' , ' --quiet ' , dest = ' quiet ' , action = ' store_true ' , help = ' Disable non-error output and assume default artist name. ' )
2020-10-16 06:59:39 +00:00
parser . add_argument ( ' -t ' , ' --threshold ' , dest = ' threshold ' , nargs = 1 , default = 300 , help = " Maximum acceptable cover art file size in kilobytes. Default: 300 " )
2019-05-04 17:55:32 +00:00
2020-10-16 08:25:02 +00:00
args = parser . parse_args ( )
2019-05-04 17:55:32 +00:00
if not os . path . exists ( args . zip ) :
2020-10-16 08:25:02 +00:00
die ( f " Couldn ' t find { args . zip } . " , 2 )
2019-05-04 17:55:32 +00:00
2020-10-16 08:25:02 +00:00
log ( " Extracting... " )
2019-05-04 17:55:32 +00:00
zipname = os . path . splitext ( os . path . basename ( args . zip ) ) [ 0 ]
2020-10-16 08:35:49 +00:00
tmp = " /tmp/bcao/ {0} " . format ( zipname ) # TODO: use OS specific temp dirs
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " rm " , " -rf " , tmp ] )
subprocess . check_output ( [ ' mkdir ' , ' -p ' , tmp ] )
subprocess . check_output ( [ " unzip " , args . zip , " -d " , tmp ] )
files = [ ]
songs = [ ]
for root , dirs , filez in os . walk ( tmp ) :
2020-10-16 08:25:02 +00:00
for file in filez : # for every file
2019-05-04 17:55:32 +00:00
files . append ( root + os . sep + file )
2020-10-16 08:35:49 +00:00
# TODO: redo everything from here to the cover art resizing
2019-05-04 17:55:32 +00:00
cover = " "
artists = [ ]
album = " "
2020-10-16 08:25:02 +00:00
fileExtensionRegex = re . compile ( r " [^.]+$ " )
probablyASongRegex = re . compile ( r " ^.+ - .+ - \ d { 2,} .+ \ .[^.]+$ " ) # matches "artist - album - 01 track.xyz" but not "some weird bonus thing.mp3"
2019-05-04 17:55:32 +00:00
musicExts = [ " ogg " , " flac " , " alac " , " aiff " , " wav " , " mp3 " , " opus " , " m4a " , " aac " , " oga " ]
2020-10-16 08:25:02 +00:00
bannedCharacters = [ " ? " , " \\ " , " / " , " : " , " | " , " * " , " \" " , " < " , " > " ] # characters disallowed in NTFS filenames
2019-05-04 17:55:32 +00:00
trackCount = 0
2020-10-16 08:25:02 +00:00
log ( " Processing... please wait. " )
2020-10-16 06:59:39 +00:00
# use "01 Song.ogg" instead of "1 Song.ogg". Also works for albums with more than 99 tracks if that ever happens
2019-05-04 17:55:32 +00:00
trackNumberLength = len ( str ( len ( files ) ) )
if trackNumberLength < 2 :
trackNumberLength = 2
for file in files :
if os . path . basename ( file ) . lower ( ) in [ " cover.png " , " cover.jpg " , " cover.gif " ] :
2020-10-16 06:59:39 +00:00
# we've found our cover art
2019-05-04 17:55:32 +00:00
cover = file
ext = re . search ( fileExtensionRegex , file . lower ( ) ) . group ( 0 )
if ext in musicExts :
if re . search ( probablyASongRegex , file ) != None :
2020-10-16 06:59:39 +00:00
# this is definitely a song and probably not a bonus sound file or whatever
2019-05-04 17:55:32 +00:00
if ext == " ogg " :
f = OggVorbis ( file )
name = " {0} {1} . {2} " . format ( f [ " TRACKNUMBER " ] [ 0 ] . zfill ( trackNumberLength ) , f [ " TITLE " ] [ 0 ] , ext )
for bc in bannedCharacters :
if bc in name :
2020-10-16 08:25:02 +00:00
name = name . replace ( bc , " - " ) # replace banned characters with dashes
2019-05-04 17:55:32 +00:00
songs . append ( name )
if album == " " :
album = f [ " ALBUM " ] [ 0 ]
if f [ " ARTIST " ] [ 0 ] not in artists :
artists . append ( f [ " ARTIST " ] [ 0 ] )
trackCount = trackCount + 1
subprocess . check_output ( [ " mv " , file , os . path . dirname ( file ) + os . sep + name ] )
else :
2020-10-16 08:25:02 +00:00
log ( " UNSUPPORTED FORMAT BECAUSE LYNNE IS A LAZY MORON " )
2019-05-04 17:55:32 +00:00
if len ( artists ) > 1 :
artists . append ( " Various Artists " )
if cover == " " :
2020-10-16 06:59:39 +00:00
# TODO: HANDLE THIS PROPERLY
2020-10-16 08:25:02 +00:00
die ( " couldn ' t find the cover art :))))) " )
2019-05-04 17:55:32 +00:00
while os . path . getsize ( cover ) / 1024 > args . threshold :
if os . path . basename ( cover ) != " cover-lq.jpg " :
nucover = os . path . dirname ( cover ) + os . sep + " cover-lq.jpg "
2020-10-16 08:25:02 +00:00
subprocess . check_output ( [ " convert " , cover , " -quality " , " 85 " , " -strip " , nucover ] ) # convert the file to a jpeg
2019-05-04 17:55:32 +00:00
cover = nucover
else :
2020-10-16 08:25:02 +00:00
subprocess . check_output ( [ " convert " , cover , " -resize " , " 90 % " , cover ] ) # shrink it slightly
2019-05-04 17:55:32 +00:00
with open ( cover , " rb " ) as cvr :
data = cvr . read ( )
imginfo = subprocess . check_output ( [ " identify " , " -ping " , " -format " , r " % w, % h, % m, % [bit-depth] " , cover ] ) . decode ( " utf-8 " ) . split ( " , " )
2020-10-16 08:35:49 +00:00
# TODO: surely you don't have to do this?? does mutagen not have a higher level way of setting an image?
2019-05-04 17:55:32 +00:00
picture = Picture ( )
picture . data = data
picture . type = 3
picture . mime = " image/ {0} " . format ( imginfo [ 2 ] . lower ( ) )
picture . width = int ( imginfo [ 0 ] )
picture . height = int ( imginfo [ 1 ] )
picture . depth = int ( imginfo [ 3 ] )
picture_data = picture . write ( )
encoded_data = base64 . b64encode ( picture_data )
vcomment_value = encoded_data . decode ( " ascii " )
for song in songs :
f = OggVorbis ( tmp + os . sep + song )
f [ " metadata_block_picture " ] = [ vcomment_value ]
f . save ( )
artist = artists [ 0 ]
artists . append ( " Custom... " )
2020-10-16 08:25:02 +00:00
# log("Please choose the artist name to use when creating the folder.")
2019-05-04 17:55:32 +00:00
choice = 0
while True :
2020-10-16 08:25:02 +00:00
log ( " Artist directory: " )
2019-05-04 17:55:32 +00:00
for i in range ( len ( artists ) ) :
2020-10-16 08:25:02 +00:00
log ( " {0} ) {1} " . format ( i + 1 , artists [ i ] ) )
choice = 1 if args . quiet else input ( " > " )
2019-05-04 17:55:32 +00:00
try :
choice = artists [ int ( choice ) - 1 ]
2020-10-16 08:25:02 +00:00
except KeyError :
log ( f " Please choose an option from 1 to { len ( artists ) } . " )
2019-05-04 17:55:32 +00:00
continue
break
if choice == " Custom... " :
2020-10-16 08:25:02 +00:00
log ( " Enter the name to use. " )
2019-05-04 17:55:32 +00:00
choice = input ( " > " )
2020-10-16 08:25:02 +00:00
# log("Setting artist to {0}".format(choice))
2019-05-04 17:55:32 +00:00
artist = choice
2020-10-16 08:25:02 +00:00
mPath = os . path . join ( args . destination , artist , album )
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " mkdir " , " -p " , mPath ] )
for root , dirs , filez in os . walk ( tmp ) :
2020-10-16 08:25:02 +00:00
for file in filez : # for every file
2020-10-16 08:35:49 +00:00
# TODO: i'm 99% sure python has a built in move command
# TODO: os.path.join
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " mv " , root + os . sep + file , mPath + os . sep + file ] )
2020-10-16 08:25:02 +00:00
log ( " Deleting {} ... " . format ( tmp ) )
2020-10-16 08:35:49 +00:00
# TODO: i'm 100% sure python has a built in remove command
2019-05-04 17:55:32 +00:00
subprocess . check_output ( [ " rm " , " -rf " , tmp ] )
2020-10-16 08:25:02 +00:00
log ( " Done! " )