""" A python class to represent a single comic, be it file or folder of images """ """ Copyright 2012 Anthony Beville Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import zipfile import os import struct import sys import tempfile import subprocess import platform import time sys.path.insert(0, os.path.abspath(".") ) import UnRAR2 from UnRAR2.rar_exceptions import * from options import Options, MetaDataStyle from comicinfoxml import ComicInfoXml from comicbookinfo import ComicBookInfo from genericmetadata import GenericMetadata from filenameparser import FileNameParser class ZipArchiver: def __init__( self, path ): self.path = path def getArchiveComment( self ): zf = zipfile.ZipFile( self.path, 'r' ) comment = zf.comment zf.close() return comment def setArchiveComment( self, comment ): self.writeZipComment( self.path, comment ) def readArchiveFile( self, archive_file ): zf = zipfile.ZipFile( self.path, 'r' ) data = zf.read( archive_file ) zf.close() return data def removeArchiveFile( self, archive_file ): self.rebuildZipFile( [ archive_file ] ) def writeArchiveFile( self, archive_file, data ): # At the moment, no other option but to rebuild the whole # zip archive w/o the indicated file. Very sucky, but maybe # another solution can be found self.rebuildZipFile( [ archive_file ] ) #now just add the archive file as a new one zf = zipfile.ZipFile(self.path, mode='a', compression=zipfile.ZIP_DEFLATED ) zf.writestr( archive_file, data ) zf.close() def getArchiveFilenameList( self ): zf = zipfile.ZipFile( self.path, 'r' ) namelist = zf.namelist() zf.close() return namelist # zip helper func def rebuildZipFile( self, exclude_list ): # TODO: use tempfile.mkstemp # this recompresses the zip archive, without the files in the exclude_list print "Rebuilding zip {0} without {1}".format( self.path, exclude_list ) zin = zipfile.ZipFile (self.path, 'r') zout = zipfile.ZipFile ('tmpnew.zip', 'w') for item in zin.infolist(): buffer = zin.read(item.filename) if ( item.filename not in exclude_list ): zout.writestr(item, buffer) #preserve the old comment zout.comment = zin.comment zout.close() zin.close() # replace with the new file os.remove( self.path ) os.rename( 'tmpnew.zip', self.path ) def writeZipComment( self, filename, comment ): """ This is a custom function for writing a comment to a zip file, since the built-in one doesn't seem to work on Windows and Mac OS/X Fortunately, the zip comment is at the end of the file, and it's easy to manipulate. See this website for more info: see: http://en.wikipedia.org/wiki/Zip_(file_format)#Structure """ #get file size statinfo = os.stat(filename) file_length = statinfo.st_size fo = open(filename, "r+b") #the starting position, relative to EOF pos = -4 found = False value = bytearray() # walk backwards to find the "End of Central Directory" record while ( not found ) and ( -pos != file_length ): # seek, relative to EOF fo.seek( pos, 2) value = fo.read( 4 ) #look for the end of central directory signature if bytearray(value) == bytearray([ 0x50, 0x4b, 0x05, 0x06 ]): found = True else: # not found, step back another byte pos = pos - 1 #print pos,"{1} int: {0:x}".format(bytearray(value)[0], value) if found: # now skip forward 20 bytes to the comment length word pos += 20 fo.seek( pos, 2) # Pack the length of the comment string format = "H" # one 2-byte integer comment_length = struct.pack(format, len(comment)) # pack integer in a binary string # write out the length fo.write( comment_length ) fo.seek( pos+2, 2) # write out the comment itself fo.write( comment ) fo.truncate() fo.close() else: raise Exception('Failed to write comment to zip file!') #------------------------------------------ # RAR implementation class RarArchiver: def __init__( self, path ): self.path = path self.rar_exe_path = None self.devnull = open(os.devnull, "w") # windows only, keeps the cmd.exe from popping up if platform.system() == "Windows": self.startupinfo = subprocess.STARTUPINFO() self.startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW else: self.startupinfo = None def __del__(self): self.devnull.close() def getArchiveComment( self ): rarc = UnRAR2.RarFile( self.path ) return rarc.comment def setArchiveComment( self, comment ): if self.rar_exe_path is not None: # write comment to temp file tmp_fd, tmp_name = tempfile.mkstemp() f = os.fdopen(tmp_fd, 'w+b') f.write( comment ) f.close() # use external program to write comment to Rar archive subprocess.call([self.rar_exe_path, 'c', '-c-', '-z' + tmp_name, self.path], startupinfo=self.startupinfo, stdout=self.devnull) if platform.system() == "Darwin": time.sleep(1) os.remove( tmp_name) def readArchiveFile( self, archive_file ): entries = UnRAR2.RarFile( self.path ).read_files( archive_file ) #entries is a list of of tuples: ( rarinfo, filedata) if (len(entries) == 1): return entries[0][1] else: return "" def writeArchiveFile( self, archive_file, data ): if self.rar_exe_path is not None: tmp_folder = tempfile.mkdtemp() tmp_file = os.path.join( tmp_folder, archive_file ) f = open(tmp_file, 'w') f.write( data ) f.close() # use external program to write file to Rar archive subprocess.call([self.rar_exe_path, 'a', '-c-', '-ep', self.path, tmp_file], startupinfo=self.startupinfo, stdout=self.devnull) if platform.system() == "Darwin": time.sleep(1) os.remove( tmp_file) os.rmdir( tmp_folder) def removeArchiveFile( self, archive_file ): if self.rar_exe_path is not None: # use external program to remove file from Rar archive subprocess.call([self.rar_exe_path, 'd','-c-', self.path, archive_file], startupinfo=self.startupinfo, stdout=self.devnull) if platform.system() == "Darwin": time.sleep(1) def getArchiveFilenameList( self ): rarc = UnRAR2.RarFile( self.path ) return [ item.filename for item in rarc.infolist() ] #------------------------------------------ # Folder implementation class FolderArchiver: def __init__( self, path ): self.path = path self.comment_file_name = "ComicTaggerFolderComment.txt" def getArchiveComment( self ): return self.readArchiveFile( self.comment_file_name ) def setArchiveComment( self, comment ): self.writeArchiveFile( self.comment_file_name, comment ) def readArchiveFile( self, archive_file ): data = "" fname = os.path.join( self.path, archive_file ) try: with open( fname, 'rb' ) as f: data = f.read() f.close() except IOError as e: pass return data def writeArchiveFile( self, archive_file, data ): fname = os.path.join( self.path, archive_file ) try: with open(fname, 'w+') as f: f.write( data ) f.close() except IOError as e: pass def removeArchiveFile( self, archive_file ): fname = os.path.join( self.path, archive_file ) try: os.remove( fname ) except: pass def getArchiveFilenameList( self ): return self.listFiles( self.path ) def listFiles( self, folder ): itemlist = list() for item in os.listdir( folder ): itemlist.append( item ) if os.path.isdir( item ): itemlist.extend( self.listFiles( os.path.join( folder, item ) )) return itemlist #------------------------------------------ # Unknown implementation class UnknownArchiver: def __init__( self, path ): self.path = path def getArchiveComment( self ): return "" def setArchiveComment( self, comment ): return def readArchiveFilen( self ): return "" def writeArchiveFile( self, archive_file, data ): return def removeArchiveFile( self, archive_file ): return def getArchiveFilenameList( self ): return [] #------------------------------------------------------------------ class ComicArchive: class ArchiveType: Zip, Rar, Folder, Unknown = range(4) def __init__( self, path ): self.path = path self.ci_xml_filename = 'ComicInfo.xml' if self.zipTest(): self.archive_type = self.ArchiveType.Zip self.archiver = ZipArchiver( self.path ) elif self.rarTest(): self.archive_type = self.ArchiveType.Rar self.archiver = RarArchiver( self.path ) elif os.path.isdir( self.path ): self.archive_type = self.ArchiveType.Folder self.archiver = FolderArchiver( self.path ) else: self.archive_type = self.ArchiveType.Unknown self.archiver = UnknownArchiver( self.path ) def setExternalRarProgram( self, rar_exe_path ): if self.isRar(): self.archiver.rar_exe_path = rar_exe_path def zipTest( self ): return zipfile.is_zipfile( self.path ) def rarTest( self ): try: rarc = UnRAR2.RarFile( self.path ) except: # InvalidRARArchive: return False else: return True def isZip( self ): return self.archive_type == self.ArchiveType.Zip def isRar( self ): return self.archive_type == self.ArchiveType.Rar def isFolder( self ): return self.archive_type == self.ArchiveType.Folder def isWritable( self ): if self.archive_type == self.ArchiveType.Unknown : return False elif self.isRar() and self.archiver.rar_exe_path is None: return False elif not os.access(self.path, os.W_OK): return False return True def seemsToBeAComicArchive( self ): ext = os.path.splitext(self.path)[1].lower() if ( ( ( ( self.isZip() ) and ( ext.lower() in [ '.zip', '.cbz' ] )) or (( self.isRar() ) and ( ext.lower() in [ '.rar', '.cbr' ] )) or ( self.isFolder() ) ) and ( self.getNumberOfPages() > 3) ): return True else: return False def readMetadata( self, style ): if style == MetaDataStyle.CIX: return self.readCIX() elif style == MetaDataStyle.CBI: return self.readCBI() else: return GenericMetadata() def writeMetadata( self, metadata, style ): if style == MetaDataStyle.CIX: self.writeCIX( metadata ) elif style == MetaDataStyle.CBI: self.writeCBI( metadata ) def hasMetadata( self, style ): if style == MetaDataStyle.CIX: return self.hasCIX() elif style == MetaDataStyle.CBI: return self.hasCBI() else: return False def removeMetadata( self, style ): if style == MetaDataStyle.CIX: self.removeCIX() elif style == MetaDataStyle.CBI: self.removeCBI() def getCoverPage(self): if self.getNumberOfPages() == 0: return None # get the list file names in the archive, and sort files = self.archiver.getArchiveFilenameList() # seems like the scanners are on Windows, and don't know about case-sensitivity! files.sort(key=lambda x: x.lower()) # find the first image file, assume it's the cover for name in files: if ( name[-4:].lower() in [ ".jpg", "jpeg", ".png" ] ): break image_data = self.archiver.readArchiveFile( name ) return image_data def getNumberOfPages(self): count = 0 for item in self.archiver.getArchiveFilenameList(): if ( item[-4:].lower() in [ ".jpg", "jpeg", ".png" ] ): count += 1 return count def readCBI( self ): if ( not self.hasCBI() ): return GenericMetadata() cbi_string = self.archiver.getArchiveComment() metadata = ComicBookInfo().metadataFromString( cbi_string ) return metadata def writeCBI( self, metadata ): cbi_string = ComicBookInfo().stringFromMetadata( metadata ) self.archiver.setArchiveComment( cbi_string ) def removeCBI( self ): self.archiver.setArchiveComment( "" ) def readCIX( self ): if not self.hasCIX(): print self.path, "doesn't has ComicInfo.xml data!" return GenericMetadata() cix_string = self.archiver.readArchiveFile( self.ci_xml_filename ) metadata = ComicInfoXml().metadataFromString( cix_string ) return metadata def writeCIX(self, metadata): if metadata is not None: cix_string = ComicInfoXml().stringFromMetadata( metadata ) self.archiver.writeArchiveFile( self.ci_xml_filename, cix_string ) def removeCIX( self ): self.archiver.removeArchiveFile( self.ci_xml_filename ) def hasCIX(self): if not self.seemsToBeAComicArchive(): return False elif self.ci_xml_filename in self.archiver.getArchiveFilenameList(): return True else: return False def hasCBI(self): #if ( not ( self.isZip() or self.isRar()) or not self.seemsToBeAComicArchive() ): if not self.seemsToBeAComicArchive(): return False comment = self.archiver.getArchiveComment() return ComicBookInfo().validateString( comment ) def metadataFromFilename( self ): metadata = GenericMetadata() fnp = FileNameParser() fnp.parseFilename( self.path ) if fnp.issue != "": metadata.issueNumber = fnp.issue if fnp.series != "": metadata.series = fnp.series if fnp.volume != "": metadata.volumeNumber = fnp.volume if fnp.year != "": metadata.publicationYear = fnp.year metadata.isEmpty = False return metadata