FIX: New CV API Rate limiting (1/s) will now be honoured (no more 400/15 mins), FIX: Removed old API lmiter/counters/display, FIX:(#1166) When more than one provider was selected, would leave out one provider when performing searches, FIX: Modified ComicTagger lib is now included due to CV API Rate limiting and causing API problems with normal ComicTagger.

This commit is contained in:
evilhero 2015-11-23 03:25:59 -05:00
parent a32264eacd
commit efe447f517
102 changed files with 18485 additions and 208 deletions

View File

@ -1,7 +1,6 @@
<%
import mylar
from mylar import version
from mylar.helpers import cvapi_check
%>
<!doctype html>
<!--[if lt IE 7 ]> <html lang="en" class="no-js ie6"> <![endif]-->
@ -102,8 +101,6 @@
%if mylar.GIT_BRANCH != 'master':
(${version.MYLAR_VERSION})
%endif
<br/>
API Usage: ${cvapi_check(True)}
</div>
</footer>
<a href="#main" id="toTop"><span>Back to top</span></a>

View File

@ -777,16 +777,18 @@
</div>
</fieldset>
<fieldset>
<legend>Metadata Tagging</legend><small class="heading"><span style="float: left; margin-right: .3em; margin-top: 4px;" class="ui-icon ui-icon-info"></span>ComicTagger and configparser are required</small>
<legend>Metadata Tagging</legend><small class="heading"><span style="float: left; margin-right: .3em; margin-top: 4px;" class="ui-icon ui-icon-info"></span>ComicTagger is inclucded but configparser is required</small>
<div class="row checkbox left clearfix">
<input id="enable_meta" type="checkbox" onclick="initConfigCheckbox($this));" name="enable_meta" value="1" ${config['enable_meta']} /><label>Enable Metadata Tagging</label>
</div>
<div class="config">
<!---
<div class="row">
<label>ComicTagger Path</label>
<input type="text" name="cmtagger_path" value="${config['cmtagger_path']}" size="30" />
<small>If left blank, will assume it's in root of mylar</small>
</div>
--->
<div class="row checkbox left clearfix">
<input type="checkbox" name="ct_tag_cr" value="1" ${config['ct_tag_cr']} /><label>Write ComicRack (cr) tags (ComicInfo.xml)</label>
</div>

View File

@ -0,0 +1,18 @@
The unrar.dll library is freeware. This means:
1. All copyrights to RAR and the unrar.dll are exclusively
owned by the author - Alexander Roshal.
2. The unrar.dll library may be used in any software to handle RAR
archives without limitations free of charge.
3. THE RAR ARCHIVER AND THE UNRAR.DLL LIBRARY ARE DISTRIBUTED "AS IS".
NO WARRANTY OF ANY KIND IS EXPRESSED OR IMPLIED. YOU USE AT
YOUR OWN RISK. THE AUTHOR WILL NOT BE LIABLE FOR DATA LOSS,
DAMAGES, LOSS OF PROFITS OR ANY OTHER KIND OF LOSS WHILE USING
OR MISUSING THIS SOFTWARE.
Thank you for your interest in RAR and unrar.dll.
Alexander L. Roshal

Binary file not shown.

View File

@ -0,0 +1,140 @@
#ifndef _UNRAR_DLL_
#define _UNRAR_DLL_
#define ERAR_END_ARCHIVE 10
#define ERAR_NO_MEMORY 11
#define ERAR_BAD_DATA 12
#define ERAR_BAD_ARCHIVE 13
#define ERAR_UNKNOWN_FORMAT 14
#define ERAR_EOPEN 15
#define ERAR_ECREATE 16
#define ERAR_ECLOSE 17
#define ERAR_EREAD 18
#define ERAR_EWRITE 19
#define ERAR_SMALL_BUF 20
#define ERAR_UNKNOWN 21
#define ERAR_MISSING_PASSWORD 22
#define RAR_OM_LIST 0
#define RAR_OM_EXTRACT 1
#define RAR_OM_LIST_INCSPLIT 2
#define RAR_SKIP 0
#define RAR_TEST 1
#define RAR_EXTRACT 2
#define RAR_VOL_ASK 0
#define RAR_VOL_NOTIFY 1
#define RAR_DLL_VERSION 4
#ifdef _UNIX
#define CALLBACK
#define PASCAL
#define LONG long
#define HANDLE void *
#define LPARAM long
#define UINT unsigned int
#endif
struct RARHeaderData
{
char ArcName[260];
char FileName[260];
unsigned int Flags;
unsigned int PackSize;
unsigned int UnpSize;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
};
struct RARHeaderDataEx
{
char ArcName[1024];
wchar_t ArcNameW[1024];
char FileName[1024];
wchar_t FileNameW[1024];
unsigned int Flags;
unsigned int PackSize;
unsigned int PackSizeHigh;
unsigned int UnpSize;
unsigned int UnpSizeHigh;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Reserved[1024];
};
struct RAROpenArchiveData
{
char *ArcName;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
};
struct RAROpenArchiveDataEx
{
char *ArcName;
wchar_t *ArcNameW;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Flags;
unsigned int Reserved[32];
};
enum UNRARCALLBACK_MESSAGES {
UCM_CHANGEVOLUME,UCM_PROCESSDATA,UCM_NEEDPASSWORD
};
typedef int (CALLBACK *UNRARCALLBACK)(UINT msg,LPARAM UserData,LPARAM P1,LPARAM P2);
typedef int (PASCAL *CHANGEVOLPROC)(char *ArcName,int Mode);
typedef int (PASCAL *PROCESSDATAPROC)(unsigned char *Addr,int Size);
#ifdef __cplusplus
extern "C" {
#endif
HANDLE PASCAL RAROpenArchive(struct RAROpenArchiveData *ArchiveData);
HANDLE PASCAL RAROpenArchiveEx(struct RAROpenArchiveDataEx *ArchiveData);
int PASCAL RARCloseArchive(HANDLE hArcData);
int PASCAL RARReadHeader(HANDLE hArcData,struct RARHeaderData *HeaderData);
int PASCAL RARReadHeaderEx(HANDLE hArcData,struct RARHeaderDataEx *HeaderData);
int PASCAL RARProcessFile(HANDLE hArcData,int Operation,char *DestPath,char *DestName);
int PASCAL RARProcessFileW(HANDLE hArcData,int Operation,wchar_t *DestPath,wchar_t *DestName);
void PASCAL RARSetCallback(HANDLE hArcData,UNRARCALLBACK Callback,LPARAM UserData);
void PASCAL RARSetChangeVolProc(HANDLE hArcData,CHANGEVOLPROC ChangeVolProc);
void PASCAL RARSetProcessDataProc(HANDLE hArcData,PROCESSDATAPROC ProcessDataProc);
void PASCAL RARSetPassword(HANDLE hArcData,char *Password);
int PASCAL RARGetDllVersion();
#ifdef __cplusplus
}
#endif
#endif

Binary file not shown.

View File

@ -0,0 +1,606 @@
UnRAR.dll Manual
~~~~~~~~~~~~~~~~
UnRAR.dll is a 32-bit Windows dynamic-link library which provides
file extraction from RAR archives.
Exported functions
====================================================================
HANDLE PASCAL RAROpenArchive(struct RAROpenArchiveData *ArchiveData)
====================================================================
Description
~~~~~~~~~~~
Open RAR archive and allocate memory structures
Parameters
~~~~~~~~~~
ArchiveData Points to RAROpenArchiveData structure
struct RAROpenArchiveData
{
char *ArcName;
UINT OpenMode;
UINT OpenResult;
char *CmtBuf;
UINT CmtBufSize;
UINT CmtSize;
UINT CmtState;
};
Structure fields:
ArcName
Input parameter which should point to zero terminated string
containing the archive name.
OpenMode
Input parameter.
Possible values
RAR_OM_LIST
Open archive for reading file headers only.
RAR_OM_EXTRACT
Open archive for testing and extracting files.
RAR_OM_LIST_INCSPLIT
Open archive for reading file headers only. If you open an archive
in such mode, RARReadHeader[Ex] will return all file headers,
including those with "file continued from previous volume" flag.
In case of RAR_OM_LIST such headers are automatically skipped.
So if you process RAR volumes in RAR_OM_LIST_INCSPLIT mode, you will
get several file header records for same file if file is split between
volumes. For such files only the last file header record will contain
the correct file CRC and if you wish to get the correct packed size,
you need to sum up packed sizes of all parts.
OpenResult
Output parameter.
Possible values
0 Success
ERAR_NO_MEMORY Not enough memory to initialize data structures
ERAR_BAD_DATA Archive header broken
ERAR_BAD_ARCHIVE File is not valid RAR archive
ERAR_UNKNOWN_FORMAT Unknown encryption used for archive headers
ERAR_EOPEN File open error
CmtBuf
Input parameter which should point to the buffer for archive
comments. Maximum comment size is limited to 64Kb. Comment text is
zero terminated. If the comment text is larger than the buffer
size, the comment text will be truncated. If CmtBuf is set to
NULL, comments will not be read.
CmtBufSize
Input parameter which should contain size of buffer for archive
comments.
CmtSize
Output parameter containing size of comments actually read into the
buffer, cannot exceed CmtBufSize.
CmtState
Output parameter.
Possible values
0 comments not present
1 Comments read completely
ERAR_NO_MEMORY Not enough memory to extract comments
ERAR_BAD_DATA Broken comment
ERAR_UNKNOWN_FORMAT Unknown comment format
ERAR_SMALL_BUF Buffer too small, comments not completely read
Return values
~~~~~~~~~~~~~
Archive handle or NULL in case of error
========================================================================
HANDLE PASCAL RAROpenArchiveEx(struct RAROpenArchiveDataEx *ArchiveData)
========================================================================
Description
~~~~~~~~~~~
Similar to RAROpenArchive, but uses RAROpenArchiveDataEx structure
allowing to specify Unicode archive name and returning information
about archive flags.
Parameters
~~~~~~~~~~
ArchiveData Points to RAROpenArchiveDataEx structure
struct RAROpenArchiveDataEx
{
char *ArcName;
wchar_t *ArcNameW;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Flags;
unsigned int Reserved[32];
};
Structure fields:
ArcNameW
Input parameter which should point to zero terminated Unicode string
containing the archive name or NULL if Unicode name is not specified.
Flags
Output parameter. Combination of bit flags.
Possible values
0x0001 - Volume attribute (archive volume)
0x0002 - Archive comment present
0x0004 - Archive lock attribute
0x0008 - Solid attribute (solid archive)
0x0010 - New volume naming scheme ('volname.partN.rar')
0x0020 - Authenticity information present
0x0040 - Recovery record present
0x0080 - Block headers are encrypted
0x0100 - First volume (set only by RAR 3.0 and later)
Reserved[32]
Reserved for future use. Must be zero.
Information on other structure fields and function return values
is available above, in RAROpenArchive function description.
====================================================================
int PASCAL RARCloseArchive(HANDLE hArcData)
====================================================================
Description
~~~~~~~~~~~
Close RAR archive and release allocated memory. It must be called when
archive processing is finished, even if the archive processing was stopped
due to an error.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Return values
~~~~~~~~~~~~~
0 Success
ERAR_ECLOSE Archive close error
====================================================================
int PASCAL RARReadHeader(HANDLE hArcData,
struct RARHeaderData *HeaderData)
====================================================================
Description
~~~~~~~~~~~
Read header of file in archive.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
HeaderData
It should point to RARHeaderData structure:
struct RARHeaderData
{
char ArcName[260];
char FileName[260];
UINT Flags;
UINT PackSize;
UINT UnpSize;
UINT HostOS;
UINT FileCRC;
UINT FileTime;
UINT UnpVer;
UINT Method;
UINT FileAttr;
char *CmtBuf;
UINT CmtBufSize;
UINT CmtSize;
UINT CmtState;
};
Structure fields:
ArcName
Output parameter which contains a zero terminated string of the
current archive name. May be used to determine the current volume
name.
FileName
Output parameter which contains a zero terminated string of the
file name in OEM (DOS) encoding.
Flags
Output parameter which contains file flags:
0x01 - file continued from previous volume
0x02 - file continued on next volume
0x04 - file encrypted with password
0x08 - file comment present
0x10 - compression of previous files is used (solid flag)
bits 7 6 5
0 0 0 - dictionary size 64 Kb
0 0 1 - dictionary size 128 Kb
0 1 0 - dictionary size 256 Kb
0 1 1 - dictionary size 512 Kb
1 0 0 - dictionary size 1024 Kb
1 0 1 - dictionary size 2048 KB
1 1 0 - dictionary size 4096 KB
1 1 1 - file is directory
Other bits are reserved.
PackSize
Output parameter means packed file size or size of the
file part if file was split between volumes.
UnpSize
Output parameter - unpacked file size.
HostOS
Output parameter - operating system used for archiving:
0 - MS DOS;
1 - OS/2.
2 - Win32
3 - Unix
FileCRC
Output parameter which contains unpacked file CRC. In case of file parts
split between volumes only the last part contains the correct CRC
and it is accessible only in RAR_OM_LIST_INCSPLIT listing mode.
FileTime
Output parameter - contains date and time in standard MS DOS format.
UnpVer
Output parameter - RAR version needed to extract file.
It is encoded as 10 * Major version + minor version.
Method
Output parameter - packing method.
FileAttr
Output parameter - file attributes.
CmtBuf
File comments support is not implemented in the new DLL version yet.
Now CmtState is always 0.
/*
* Input parameter which should point to the buffer for file
* comments. Maximum comment size is limited to 64Kb. Comment text is
* a zero terminated string in OEM encoding. If the comment text is
* larger than the buffer size, the comment text will be truncated.
* If CmtBuf is set to NULL, comments will not be read.
*/
CmtBufSize
Input parameter which should contain size of buffer for archive
comments.
CmtSize
Output parameter containing size of comments actually read into the
buffer, should not exceed CmtBufSize.
CmtState
Output parameter.
Possible values
0 Absent comments
1 Comments read completely
ERAR_NO_MEMORY Not enough memory to extract comments
ERAR_BAD_DATA Broken comment
ERAR_UNKNOWN_FORMAT Unknown comment format
ERAR_SMALL_BUF Buffer too small, comments not completely read
Return values
~~~~~~~~~~~~~
0 Success
ERAR_END_ARCHIVE End of archive
ERAR_BAD_DATA File header broken
====================================================================
int PASCAL RARReadHeaderEx(HANDLE hArcData,
struct RARHeaderDataEx *HeaderData)
====================================================================
Description
~~~~~~~~~~~
Similar to RARReadHeader, but uses RARHeaderDataEx structure,
containing information about Unicode file names and 64 bit file sizes.
struct RARHeaderDataEx
{
char ArcName[1024];
wchar_t ArcNameW[1024];
char FileName[1024];
wchar_t FileNameW[1024];
unsigned int Flags;
unsigned int PackSize;
unsigned int PackSizeHigh;
unsigned int UnpSize;
unsigned int UnpSizeHigh;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Reserved[1024];
};
====================================================================
int PASCAL RARProcessFile(HANDLE hArcData,
int Operation,
char *DestPath,
char *DestName)
====================================================================
Description
~~~~~~~~~~~
Performs action and moves the current position in the archive to
the next file. Extract or test the current file from the archive
opened in RAR_OM_EXTRACT mode. If the mode RAR_OM_LIST is set,
then a call to this function will simply skip the archive position
to the next file.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Operation
File operation.
Possible values
RAR_SKIP Move to the next file in the archive. If the
archive is solid and RAR_OM_EXTRACT mode was set
when the archive was opened, the current file will
be processed - the operation will be performed
slower than a simple seek.
RAR_TEST Test the current file and move to the next file in
the archive. If the archive was opened with
RAR_OM_LIST mode, the operation is equal to
RAR_SKIP.
RAR_EXTRACT Extract the current file and move to the next file.
If the archive was opened with RAR_OM_LIST mode,
the operation is equal to RAR_SKIP.
DestPath
This parameter should point to a zero terminated string containing the
destination directory to which to extract files to. If DestPath is equal
to NULL, it means extract to the current directory. This parameter has
meaning only if DestName is NULL.
DestName
This parameter should point to a string containing the full path and name
to assign to extracted file or it can be NULL to use the default name.
If DestName is defined (not NULL), it overrides both the original file
name saved in the archive and path specigied in DestPath setting.
Both DestPath and DestName must be in OEM encoding. If necessary,
use CharToOem to convert text to OEM before passing to this function.
Return values
~~~~~~~~~~~~~
0 Success
ERAR_BAD_DATA File CRC error
ERAR_BAD_ARCHIVE Volume is not valid RAR archive
ERAR_UNKNOWN_FORMAT Unknown archive format
ERAR_EOPEN Volume open error
ERAR_ECREATE File create error
ERAR_ECLOSE File close error
ERAR_EREAD Read error
ERAR_EWRITE Write error
Note: if you wish to cancel extraction, return -1 when processing
UCM_PROCESSDATA callback message.
====================================================================
int PASCAL RARProcessFileW(HANDLE hArcData,
int Operation,
wchar_t *DestPath,
wchar_t *DestName)
====================================================================
Description
~~~~~~~~~~~
Unicode version of RARProcessFile. It uses Unicode DestPath
and DestName parameters, other parameters and return values
are the same as in RARProcessFile.
====================================================================
void PASCAL RARSetCallback(HANDLE hArcData,
int PASCAL (*CallbackProc)(UINT msg,LPARAM UserData,LPARAM P1,LPARAM P2),
LPARAM UserData);
====================================================================
Description
~~~~~~~~~~~
Set a user-defined callback function to process Unrar events.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
CallbackProc
It should point to a user-defined callback function.
The function will be passed four parameters:
msg Type of event. Described below.
UserData User defined value passed to RARSetCallback.
P1 and P2 Event dependent parameters. Described below.
Possible events
UCM_CHANGEVOLUME Process volume change.
P1 Points to the zero terminated name
of the next volume.
P2 The function call mode:
RAR_VOL_ASK Required volume is absent. The function should
prompt user and return a positive value
to retry or return -1 value to terminate
operation. The function may also specify a new
volume name, placing it to the address specified
by P1 parameter.
RAR_VOL_NOTIFY Required volume is successfully opened.
This is a notification call and volume name
modification is not allowed. The function should
return a positive value to continue or -1
to terminate operation.
UCM_PROCESSDATA Process unpacked data. It may be used to read
a file while it is being extracted or tested
without actual extracting file to disk.
Return a positive value to continue process
or -1 to cancel the archive operation
P1 Address pointing to the unpacked data.
Function may refer to the data but must not
change it.
P2 Size of the unpacked data. It is guaranteed
only that the size will not exceed the maximum
dictionary size (4 Mb in RAR 3.0).
UCM_NEEDPASSWORD DLL needs a password to process archive.
This message must be processed if you wish
to be able to handle archives with encrypted
file names. It can be also used as replacement
of RARSetPassword function even for usual
encrypted files with non-encrypted names.
P1 Address pointing to the buffer for a password.
You need to copy a password here.
P2 Size of the password buffer.
UserData
User data passed to callback function.
Other functions of UnRAR.dll should not be called from the callback
function.
Return values
~~~~~~~~~~~~~
None
====================================================================
void PASCAL RARSetChangeVolProc(HANDLE hArcData,
int PASCAL (*ChangeVolProc)(char *ArcName,int Mode));
====================================================================
Obsoleted, use RARSetCallback instead.
====================================================================
void PASCAL RARSetProcessDataProc(HANDLE hArcData,
int PASCAL (*ProcessDataProc)(unsigned char *Addr,int Size))
====================================================================
Obsoleted, use RARSetCallback instead.
====================================================================
void PASCAL RARSetPassword(HANDLE hArcData,
char *Password);
====================================================================
Description
~~~~~~~~~~~
Set a password to decrypt files.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Password
It should point to a string containing a zero terminated password.
Return values
~~~~~~~~~~~~~
None
====================================================================
void PASCAL RARGetDllVersion();
====================================================================
Description
~~~~~~~~~~~
Returns API version.
Parameters
~~~~~~~~~~
None.
Return values
~~~~~~~~~~~~~
Returns an integer value denoting UnRAR.dll API version, which is also
defined in unrar.h as RAR_DLL_VERSION. API version number is incremented
only in case of noticeable changes in UnRAR.dll API. Do not confuse it
with version of UnRAR.dll stored in DLL resources, which is incremented
with every DLL rebuild.
If RARGetDllVersion() returns a value lower than UnRAR.dll which your
application was designed for, it may indicate that DLL version is too old
and it will fail to provide all necessary functions to your application.
This function is absent in old versions of UnRAR.dll, so it is safer
to use LoadLibrary and GetProcAddress to access this function.

View File

@ -0,0 +1,80 @@
List of unrar.dll API changes. We do not include performance and reliability
improvements into this list, but this library and RAR/UnRAR tools share
the same source code. So the latest version of unrar.dll usually contains
same decompression algorithm changes as the latest UnRAR version.
============================================================================
-- 18 January 2008
all LONG parameters of CallbackProc function were changed
to LPARAM type for 64 bit mode compatibility.
-- 12 December 2007
Added new RAR_OM_LIST_INCSPLIT open mode for function RAROpenArchive.
-- 14 August 2007
Added NoCrypt\unrar_nocrypt.dll without decryption code for those
applications where presence of encryption or decryption code is not
allowed because of legal restrictions.
-- 14 December 2006
Added ERAR_MISSING_PASSWORD error type. This error is returned
if empty password is specified for encrypted file.
-- 12 June 2003
Added RARProcessFileW function, Unicode version of RARProcessFile
-- 9 August 2002
Added RAROpenArchiveEx function allowing to specify Unicode archive
name and get archive flags.
-- 24 January 2002
Added RARReadHeaderEx function allowing to read Unicode file names
and 64 bit file sizes.
-- 23 January 2002
Added ERAR_UNKNOWN error type (it is used for all errors which
do not have special ERAR code yet) and UCM_NEEDPASSWORD callback
message.
Unrar.dll automatically opens all next volumes not only when extracting,
but also in RAR_OM_LIST mode.
-- 27 November 2001
RARSetChangeVolProc and RARSetProcessDataProc are replaced by
the single callback function installed with RARSetCallback.
Unlike old style callbacks, the new function accepts the user defined
parameter. Unrar.dll still supports RARSetChangeVolProc and
RARSetProcessDataProc for compatibility purposes, but if you write
a new application, better use RARSetCallback.
File comments support is not implemented in the new DLL version yet.
Now CmtState is always 0.
-- 13 August 2001
Added RARGetDllVersion function, so you may distinguish old unrar.dll,
which used C style callback functions and the new one with PASCAL callbacks.
-- 10 May 2001
Callback functions in RARSetChangeVolProc and RARSetProcessDataProc
use PASCAL style call convention now.

View File

@ -0,0 +1 @@
This is x64 version of unrar.dll.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,177 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
pyUnRAR2 is a ctypes based wrapper around the free UnRAR.dll.
It is an modified version of Jimmy Retzlaff's pyUnRAR - more simple,
stable and foolproof.
Notice that it has INCOMPATIBLE interface.
It enables reading and unpacking of archives created with the
RAR/WinRAR archivers. There is a low-level interface which is very
similar to the C interface provided by UnRAR. There is also a
higher level interface which makes some common operations easier.
"""
__version__ = '0.99.3'
try:
WindowsError
in_windows = True
except NameError:
in_windows = False
if in_windows:
from windows import RarFileImplementation
else:
from unix import RarFileImplementation
import fnmatch, time, weakref
class RarInfo(object):
"""Represents a file header in an archive. Don't instantiate directly.
Use only to obtain information about file.
YOU CANNOT EXTRACT FILE CONTENTS USING THIS OBJECT.
USE METHODS OF RarFile CLASS INSTEAD.
Properties:
index - index of file within the archive
filename - name of the file in the archive including path (if any)
datetime - file date/time as a struct_time suitable for time.strftime
isdir - True if the file is a directory
size - size in bytes of the uncompressed file
comment - comment associated with the file
Note - this is not currently intended to be a Python file-like object.
"""
def __init__(self, rarfile, data):
self.rarfile = weakref.proxy(rarfile)
self.index = data['index']
self.filename = data['filename']
self.isdir = data['isdir']
self.size = data['size']
self.datetime = data['datetime']
self.comment = data['comment']
def __str__(self):
try :
arcName = self.rarfile.archiveName
except ReferenceError:
arcName = "[ARCHIVE_NO_LONGER_LOADED]"
return '<RarInfo "%s" in "%s">' % (self.filename, arcName)
class RarFile(RarFileImplementation):
def __init__(self, archiveName, password=None):
"""Instantiate the archive.
archiveName is the name of the RAR file.
password is used to decrypt the files in the archive.
Properties:
comment - comment associated with the archive
>>> print RarFile('test.rar').comment
This is a test.
"""
self.archiveName = archiveName
RarFileImplementation.init(self, password)
def __del__(self):
self.destruct()
def infoiter(self):
"""Iterate over all the files in the archive, generating RarInfos.
>>> import os
>>> for fileInArchive in RarFile('test.rar').infoiter():
... print os.path.split(fileInArchive.filename)[-1],
... print fileInArchive.isdir,
... print fileInArchive.size,
... print fileInArchive.comment,
... print tuple(fileInArchive.datetime)[0:5],
... print time.strftime('%a, %d %b %Y %H:%M', fileInArchive.datetime)
test True 0 None (2003, 6, 30, 1, 59) Mon, 30 Jun 2003 01:59
test.txt False 20 None (2003, 6, 30, 2, 1) Mon, 30 Jun 2003 02:01
this.py False 1030 None (2002, 2, 8, 16, 47) Fri, 08 Feb 2002 16:47
"""
for params in RarFileImplementation.infoiter(self):
yield RarInfo(self, params)
def infolist(self):
"""Return a list of RarInfos, descripting the contents of the archive."""
return list(self.infoiter())
def read_files(self, condition='*'):
"""Read specific files from archive into memory.
If "condition" is a list of numbers, then return files which have those positions in infolist.
If "condition" is a string, then it is treated as a wildcard for names of files to extract.
If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object
and returns boolean True (extract) or False (skip).
If "condition" is omitted, all files are returned.
Returns list of tuples (RarInfo info, str contents)
"""
checker = condition2checker(condition)
return RarFileImplementation.read_files(self, checker)
def extract(self, condition='*', path='.', withSubpath=True, overwrite=True):
"""Extract specific files from archive to disk.
If "condition" is a list of numbers, then extract files which have those positions in infolist.
If "condition" is a string, then it is treated as a wildcard for names of files to extract.
If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object
and returns either boolean True (extract) or boolean False (skip).
DEPRECATED: If "condition" callback returns string (only supported for Windows) -
that string will be used as a new name to save the file under.
If "condition" is omitted, all files are extracted.
"path" is a directory to extract to
"withSubpath" flag denotes whether files are extracted with their full path in the archive.
"overwrite" flag denotes whether extracted files will overwrite old ones. Defaults to true.
Returns list of RarInfos for extracted files."""
checker = condition2checker(condition)
return RarFileImplementation.extract(self, checker, path, withSubpath, overwrite)
def condition2checker(condition):
"""Converts different condition types to callback"""
if type(condition) in [str, unicode]:
def smatcher(info):
return fnmatch.fnmatch(info.filename, condition)
return smatcher
elif type(condition) in [list, tuple] and type(condition[0]) in [int, long]:
def imatcher(info):
return info.index in condition
return imatcher
elif callable(condition):
return condition
else:
raise TypeError

View File

@ -0,0 +1,30 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Low level interface - see UnRARDLL\UNRARDLL.TXT
class ArchiveHeaderBroken(Exception): pass
class InvalidRARArchive(Exception): pass
class FileOpenError(Exception): pass
class IncorrectRARPassword(Exception): pass
class InvalidRARArchiveUsage(Exception): pass

View File

@ -0,0 +1,138 @@
import os, sys
import UnRAR2
from UnRAR2.rar_exceptions import *
def cleanup(dir='test'):
for path, dirs, files in os.walk(dir):
for fn in files:
os.remove(os.path.join(path, fn))
for dir in dirs:
os.removedirs(os.path.join(path, dir))
# basic test
cleanup()
rarc = UnRAR2.RarFile('test.rar')
rarc.infolist()
assert rarc.comment == "This is a test."
for info in rarc.infoiter():
saveinfo = info
assert (str(info)=="""<RarInfo "test" in "test.rar">""")
break
rarc.extract()
assert os.path.exists('test'+os.sep+'test.txt')
assert os.path.exists('test'+os.sep+'this.py')
del rarc
assert (str(saveinfo)=="""<RarInfo "test" in "[ARCHIVE_NO_LONGER_LOADED]">""")
cleanup()
# extract all the files in test.rar
cleanup()
UnRAR2.RarFile('test.rar').extract()
assert os.path.exists('test'+os.sep+'test.txt')
assert os.path.exists('test'+os.sep+'this.py')
cleanup()
# extract all the files in test.rar matching the wildcard *.txt
cleanup()
UnRAR2.RarFile('test.rar').extract('*.txt')
assert os.path.exists('test'+os.sep+'test.txt')
assert not os.path.exists('test'+os.sep+'this.py')
cleanup()
# check the name and size of each file, extracting small ones
cleanup()
archive = UnRAR2.RarFile('test.rar')
assert archive.comment == 'This is a test.'
archive.extract(lambda rarinfo: rarinfo.size <= 1024)
for rarinfo in archive.infoiter():
if rarinfo.size <= 1024 and not rarinfo.isdir:
assert rarinfo.size == os.stat(rarinfo.filename).st_size
assert file('test'+os.sep+'test.txt', 'rt').read() == 'This is only a test.'
assert not os.path.exists('test'+os.sep+'this.py')
cleanup()
# extract this.py, overriding it's destination
cleanup('test2')
archive = UnRAR2.RarFile('test.rar')
archive.extract('*.py', 'test2', False)
assert os.path.exists('test2'+os.sep+'this.py')
cleanup('test2')
# extract test.txt to memory
cleanup()
archive = UnRAR2.RarFile('test.rar')
entries = UnRAR2.RarFile('test.rar').read_files('*test.txt')
assert len(entries)==1
assert entries[0][0].filename.endswith('test.txt')
assert entries[0][1]=='This is only a test.'
# extract all the files in test.rar with overwriting
cleanup()
fo = open('test'+os.sep+'test.txt',"wt")
fo.write("blah")
fo.close()
UnRAR2.RarFile('test.rar').extract('*.txt')
assert open('test'+os.sep+'test.txt',"rt").read()!="blah"
cleanup()
# extract all the files in test.rar without overwriting
cleanup()
fo = open('test'+os.sep+'test.txt',"wt")
fo.write("blahblah")
fo.close()
UnRAR2.RarFile('test.rar').extract('*.txt', overwrite = False)
assert open('test'+os.sep+'test.txt',"rt").read()=="blahblah"
cleanup()
# list big file in an archive
list(UnRAR2.RarFile('test_nulls.rar').infoiter())
# extract files from an archive with protected files
cleanup()
rarc = UnRAR2.RarFile('test_protected_files.rar', password="protected")
rarc.extract()
assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
cleanup()
errored = False
try:
UnRAR2.RarFile('test_protected_files.rar', password="proteqted").extract()
except IncorrectRARPassword:
errored = True
assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
assert errored
cleanup()
# extract files from an archive with protected headers
cleanup()
UnRAR2.RarFile('test_protected_headers.rar', password="secret").extract()
assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
cleanup()
errored = False
try:
UnRAR2.RarFile('test_protected_headers.rar', password="seqret").extract()
except IncorrectRARPassword:
errored = True
assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
assert errored
cleanup()
# make sure docstring examples are working
import doctest
doctest.testmod(UnRAR2)
# update documentation
import pydoc
pydoc.writedoc(UnRAR2)
# cleanup
try:
os.remove('__init__.pyc')
except:
pass

View File

@ -0,0 +1,218 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Unix version uses unrar command line executable
import subprocess
import gc
import os, os.path
import time, re
from rar_exceptions import *
class UnpackerNotInstalled(Exception): pass
rar_executable_cached = None
rar_executable_version = None
def call_unrar(params):
"Calls rar/unrar command line executable, returns stdout pipe"
global rar_executable_cached
if rar_executable_cached is None:
for command in ('unrar', 'rar'):
try:
subprocess.Popen([command], stdout=subprocess.PIPE)
rar_executable_cached = command
break
except OSError:
pass
if rar_executable_cached is None:
raise UnpackerNotInstalled("No suitable RAR unpacker installed")
assert type(params) == list, "params must be list"
args = [rar_executable_cached] + params
try:
gc.disable() # See http://bugs.python.org/issue1336
return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
finally:
gc.enable()
class RarFileImplementation(object):
def init(self, password=None):
global rar_executable_version
self.password = password
stdoutdata, stderrdata = self.call('v', []).communicate()
for line in stderrdata.splitlines():
if line.strip().startswith("Cannot open"):
raise FileOpenError
if line.find("CRC failed")>=0:
raise IncorrectRARPassword
accum = []
source = iter(stdoutdata.splitlines())
line = ''
while not (line.startswith('UNRAR')):
line = source.next()
signature = line
# The code below is mighty flaky
# and will probably crash on localized versions of RAR
# but I see no safe way to rewrite it using a CLI tool
if signature.startswith("UNRAR 4"):
rar_executable_version = 4
while not (line.startswith('Comment:') or line.startswith('Pathname/Comment')):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
line = source.next()
while not line.startswith('Pathname/Comment'):
accum.append(line.rstrip('\n'))
line = source.next()
if len(accum):
accum[0] = accum[0][9:] # strip out "Comment:" part
self.comment = '\n'.join(accum[:-1])
else:
self.comment = None
elif signature.startswith("UNRAR 5"):
rar_executable_version = 5
line = source.next()
while not line.startswith('Archive:'):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
accum.append(line.rstrip('\n'))
line = source.next()
if len(accum):
self.comment = '\n'.join(accum[:-1]).strip()
else:
self.comment = None
else:
raise UnpackerNotInstalled("Unsupported RAR version, expected 4.x or 5.x, found: "
+ signature.split(" ")[1])
def escaped_password(self):
return '-' if self.password == None else self.password
def call(self, cmd, options=[], files=[]):
options2 = options + ['p'+self.escaped_password()]
soptions = ['-'+x for x in options2]
return call_unrar([cmd]+soptions+['--',self.archiveName]+files)
def infoiter(self):
command = "v" if rar_executable_version == 4 else "l"
stdoutdata, stderrdata = self.call(command, ['c-']).communicate()
for line in stderrdata.splitlines():
if line.strip().startswith("Cannot open"):
raise FileOpenError
accum = []
source = iter(stdoutdata.splitlines())
line = ''
while not line.startswith('-----------'):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
if line.startswith("CRC failed") or line.startswith("Checksum error"):
raise IncorrectRARPassword
line = source.next()
line = source.next()
i = 0
re_spaces = re.compile(r"\s+")
if rar_executable_version == 4:
while not line.startswith('-----------'):
accum.append(line)
if len(accum)==2:
data = {}
data['index'] = i
# asterisks mark password-encrypted files
data['filename'] = accum[0].strip().lstrip("*") # asterisks marks password-encrypted files
fields = re_spaces.split(accum[1].strip())
data['size'] = int(fields[0])
attr = fields[5]
data['isdir'] = 'd' in attr.lower()
data['datetime'] = time.strptime(fields[3]+" "+fields[4], '%d-%m-%y %H:%M')
data['comment'] = None
yield data
accum = []
i += 1
line = source.next()
elif rar_executable_version == 5:
while not line.startswith('-----------'):
fields = line.strip().lstrip("*").split()
data = {}
data['index'] = i
data['filename'] = " ".join(fields[4:])
data['size'] = int(fields[1])
attr = fields[0]
data['isdir'] = 'd' in attr.lower()
data['datetime'] = time.strptime(fields[2]+" "+fields[3], '%d-%m-%y %H:%M')
data['comment'] = None
yield data
i += 1
line = source.next()
def read_files(self, checker):
res = []
for info in self.infoiter():
checkres = checker(info)
if checkres==True and not info.isdir:
pipe = self.call('p', ['inul'], [info.filename]).stdout
res.append((info, pipe.read()))
return res
def extract(self, checker, path, withSubpath, overwrite):
res = []
command = 'x'
if not withSubpath:
command = 'e'
options = []
if overwrite:
options.append('o+')
else:
options.append('o-')
if not path.endswith(os.sep):
path += os.sep
names = []
for info in self.infoiter():
checkres = checker(info)
if type(checkres) in [str, unicode]:
raise NotImplementedError("Condition callbacks returning strings are deprecated and only supported in Windows")
if checkres==True and not info.isdir:
names.append(info.filename)
res.append(info)
names.append(path)
proc = self.call(command, options, names)
stdoutdata, stderrdata = proc.communicate()
if stderrdata.find("CRC failed")>=0 or stderrdata.find("Checksum error")>=0:
raise IncorrectRARPassword
return res
def destruct(self):
pass

View File

@ -0,0 +1,309 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Low level interface - see UnRARDLL\UNRARDLL.TXT
from __future__ import generators
import ctypes, ctypes.wintypes
import os, os.path, sys
import Queue
import time
from rar_exceptions import *
ERAR_END_ARCHIVE = 10
ERAR_NO_MEMORY = 11
ERAR_BAD_DATA = 12
ERAR_BAD_ARCHIVE = 13
ERAR_UNKNOWN_FORMAT = 14
ERAR_EOPEN = 15
ERAR_ECREATE = 16
ERAR_ECLOSE = 17
ERAR_EREAD = 18
ERAR_EWRITE = 19
ERAR_SMALL_BUF = 20
ERAR_UNKNOWN = 21
RAR_OM_LIST = 0
RAR_OM_EXTRACT = 1
RAR_SKIP = 0
RAR_TEST = 1
RAR_EXTRACT = 2
RAR_VOL_ASK = 0
RAR_VOL_NOTIFY = 1
RAR_DLL_VERSION = 3
# enum UNRARCALLBACK_MESSAGES
UCM_CHANGEVOLUME = 0
UCM_PROCESSDATA = 1
UCM_NEEDPASSWORD = 2
architecture_bits = ctypes.sizeof(ctypes.c_voidp)*8
dll_name = "unrar.dll"
if architecture_bits == 64:
dll_name = "x64\\unrar64.dll"
try:
unrar = ctypes.WinDLL(os.path.join(os.path.split(__file__)[0], 'UnRARDLL', dll_name))
except WindowsError:
unrar = ctypes.WinDLL(dll_name)
class RAROpenArchiveDataEx(ctypes.Structure):
def __init__(self, ArcName=None, ArcNameW=u'', OpenMode=RAR_OM_LIST):
self.CmtBuf = ctypes.c_buffer(64*1024)
ctypes.Structure.__init__(self, ArcName=ArcName, ArcNameW=ArcNameW, OpenMode=OpenMode, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf))
_fields_ = [
('ArcName', ctypes.c_char_p),
('ArcNameW', ctypes.c_wchar_p),
('OpenMode', ctypes.c_uint),
('OpenResult', ctypes.c_uint),
('_CmtBuf', ctypes.c_voidp),
('CmtBufSize', ctypes.c_uint),
('CmtSize', ctypes.c_uint),
('CmtState', ctypes.c_uint),
('Flags', ctypes.c_uint),
('Reserved', ctypes.c_uint*32),
]
class RARHeaderDataEx(ctypes.Structure):
def __init__(self):
self.CmtBuf = ctypes.c_buffer(64*1024)
ctypes.Structure.__init__(self, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf))
_fields_ = [
('ArcName', ctypes.c_char*1024),
('ArcNameW', ctypes.c_wchar*1024),
('FileName', ctypes.c_char*1024),
('FileNameW', ctypes.c_wchar*1024),
('Flags', ctypes.c_uint),
('PackSize', ctypes.c_uint),
('PackSizeHigh', ctypes.c_uint),
('UnpSize', ctypes.c_uint),
('UnpSizeHigh', ctypes.c_uint),
('HostOS', ctypes.c_uint),
('FileCRC', ctypes.c_uint),
('FileTime', ctypes.c_uint),
('UnpVer', ctypes.c_uint),
('Method', ctypes.c_uint),
('FileAttr', ctypes.c_uint),
('_CmtBuf', ctypes.c_voidp),
('CmtBufSize', ctypes.c_uint),
('CmtSize', ctypes.c_uint),
('CmtState', ctypes.c_uint),
('Reserved', ctypes.c_uint*1024),
]
def DosDateTimeToTimeTuple(dosDateTime):
"""Convert an MS-DOS format date time to a Python time tuple.
"""
dosDate = dosDateTime >> 16
dosTime = dosDateTime & 0xffff
day = dosDate & 0x1f
month = (dosDate >> 5) & 0xf
year = 1980 + (dosDate >> 9)
second = 2*(dosTime & 0x1f)
minute = (dosTime >> 5) & 0x3f
hour = dosTime >> 11
return time.localtime(time.mktime((year, month, day, hour, minute, second, 0, 1, -1)))
def _wrap(restype, function, argtypes):
result = function
result.argtypes = argtypes
result.restype = restype
return result
RARGetDllVersion = _wrap(ctypes.c_int, unrar.RARGetDllVersion, [])
RAROpenArchiveEx = _wrap(ctypes.wintypes.HANDLE, unrar.RAROpenArchiveEx, [ctypes.POINTER(RAROpenArchiveDataEx)])
RARReadHeaderEx = _wrap(ctypes.c_int, unrar.RARReadHeaderEx, [ctypes.wintypes.HANDLE, ctypes.POINTER(RARHeaderDataEx)])
_RARSetPassword = _wrap(ctypes.c_int, unrar.RARSetPassword, [ctypes.wintypes.HANDLE, ctypes.c_char_p])
def RARSetPassword(*args, **kwargs):
_RARSetPassword(*args, **kwargs)
RARProcessFile = _wrap(ctypes.c_int, unrar.RARProcessFile, [ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p])
RARCloseArchive = _wrap(ctypes.c_int, unrar.RARCloseArchive, [ctypes.wintypes.HANDLE])
UNRARCALLBACK = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint, ctypes.c_long, ctypes.c_long, ctypes.c_long)
RARSetCallback = _wrap(ctypes.c_int, unrar.RARSetCallback, [ctypes.wintypes.HANDLE, UNRARCALLBACK, ctypes.c_long])
RARExceptions = {
ERAR_NO_MEMORY : MemoryError,
ERAR_BAD_DATA : ArchiveHeaderBroken,
ERAR_BAD_ARCHIVE : InvalidRARArchive,
ERAR_EOPEN : FileOpenError,
}
class PassiveReader:
"""Used for reading files to memory"""
def __init__(self, usercallback = None):
self.buf = []
self.ucb = usercallback
def _callback(self, msg, UserData, P1, P2):
if msg == UCM_PROCESSDATA:
data = (ctypes.c_char*P2).from_address(P1).raw
if self.ucb!=None:
self.ucb(data)
else:
self.buf.append(data)
return 1
def get_result(self):
return ''.join(self.buf)
class RarInfoIterator(object):
def __init__(self, arc):
self.arc = arc
self.index = 0
self.headerData = RARHeaderDataEx()
self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData))
if self.res==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.arc.lockStatus = "locked"
self.arc.needskip = False
def __iter__(self):
return self
def next(self):
if self.index>0:
if self.arc.needskip:
RARProcessFile(self.arc._handle, RAR_SKIP, None, None)
self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData))
if self.res:
raise StopIteration
self.arc.needskip = True
data = {}
data['index'] = self.index
data['filename'] = self.headerData.FileName
data['datetime'] = DosDateTimeToTimeTuple(self.headerData.FileTime)
data['isdir'] = ((self.headerData.Flags & 0xE0) == 0xE0)
data['size'] = self.headerData.UnpSize + (self.headerData.UnpSizeHigh << 32)
if self.headerData.CmtState == 1:
data['comment'] = self.headerData.CmtBuf.value
else:
data['comment'] = None
self.index += 1
return data
def __del__(self):
self.arc.lockStatus = "finished"
def generate_password_provider(password):
def password_provider_callback(msg, UserData, P1, P2):
if msg == UCM_NEEDPASSWORD and password!=None:
(ctypes.c_char*P2).from_address(P1).value = password
return 1
return password_provider_callback
class RarFileImplementation(object):
def init(self, password=None):
self.password = password
archiveData = RAROpenArchiveDataEx(ArcNameW=self.archiveName, OpenMode=RAR_OM_EXTRACT)
self._handle = RAROpenArchiveEx(ctypes.byref(archiveData))
self.c_callback = UNRARCALLBACK(generate_password_provider(self.password))
RARSetCallback(self._handle, self.c_callback, 1)
if archiveData.OpenResult != 0:
raise RARExceptions[archiveData.OpenResult]
if archiveData.CmtState == 1:
self.comment = archiveData.CmtBuf.value
else:
self.comment = None
if password:
RARSetPassword(self._handle, password)
self.lockStatus = "ready"
def destruct(self):
if self._handle and RARCloseArchive:
RARCloseArchive(self._handle)
def make_sure_ready(self):
if self.lockStatus == "locked":
raise InvalidRARArchiveUsage("cannot execute infoiter() without finishing previous one")
if self.lockStatus == "finished":
self.destruct()
self.init(self.password)
def infoiter(self):
self.make_sure_ready()
return RarInfoIterator(self)
def read_files(self, checker):
res = []
for info in self.infoiter():
if checker(info) and not info.isdir:
reader = PassiveReader()
c_callback = UNRARCALLBACK(reader._callback)
RARSetCallback(self._handle, c_callback, 1)
tmpres = RARProcessFile(self._handle, RAR_TEST, None, None)
if tmpres==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.needskip = False
res.append((info, reader.get_result()))
return res
def extract(self, checker, path, withSubpath, overwrite):
res = []
for info in self.infoiter():
checkres = checker(info)
if checkres!=False and not info.isdir:
if checkres==True:
fn = info.filename
if not withSubpath:
fn = os.path.split(fn)[-1]
target = os.path.join(path, fn)
else:
raise DeprecationWarning, "Condition callbacks returning strings are deprecated and only supported in Windows"
target = checkres
if overwrite or (not os.path.exists(target)):
tmpres = RARProcessFile(self._handle, RAR_EXTRACT, None, target)
if tmpres==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.needskip = False
res.append(info)
return res

View File

View File

@ -0,0 +1,233 @@
"""
A PyQT4 dialog to select from automated issue matches
"""
"""
Copyright 2012-2014 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 sys
import os
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from comicarchive import MetaDataStyle
from coverimagewidget import CoverImageWidget
from comicvinetalker import ComicVineTalker
import utils
class AutoTagMatchWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, match_set_list, style, fetch_func):
super(AutoTagMatchWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('matchselectionwindow.ui' ), self)
self.altCoverWidget = CoverImageWidget( self.altCoverContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.altCoverContainer )
gridlayout.addWidget( self.altCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.skipButton = QtGui.QPushButton(self.tr("Skip to Next"))
self.buttonBox.addButton(self.skipButton, QtGui.QDialogButtonBox.ActionRole)
self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setText("Accept and Write Tags")
self.match_set_list = match_set_list
self.style = style
self.fetch_func = fetch_func
self.current_match_set_idx = 0
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.skipButton.clicked.connect(self.skipToNext)
self.updateData()
def updateData( self):
self.current_match_set = self.match_set_list[ self.current_match_set_idx ]
if self.current_match_set_idx + 1 == len( self.match_set_list ):
self.buttonBox.button(QtGui.QDialogButtonBox.Cancel).setDisabled(True)
#self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setText("Accept")
self.skipButton.setText(self.tr("Skip"))
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow( 0 )
path = self.current_match_set.ca.path
self.setWindowTitle( u"Select correct match or skip ({0} of {1}): {2}".format(
self.current_match_set_idx+1,
len( self.match_set_list ),
os.path.split(path)[1] ))
def populateTable( self ):
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
row = 0
for match in self.current_match_set.matches:
self.twList.insertRow(row)
item_text = match['series']
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match['publisher'] is not None:
item_text = u"{0}".format(match['publisher'])
else:
item_text = u"Unknown"
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = u""
year_str = u"????"
if match['month'] is not None:
month_str = u"-{0:02d}".format(int(match['month']))
if match['year'] is not None:
year_str = u"{0}".format(match['year'])
item_text = year_str + month_str
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match['issue_title']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems( 2 , QtCore.Qt.AscendingOrder )
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.setIssueID( self.currentMatch()['issue_id'] )
if self.currentMatch()['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( self.currentMatch()['description'] )
def setCoverImage( self ):
ca = self.current_match_set.ca
self.archiveCoverWidget.setArchive(ca)
def currentMatch( self ):
row = self.twList.currentRow()
match = self.twList.item(row, 0).data( QtCore.Qt.UserRole ).toPyObject()[0]
return match
def accept(self):
self.saveMatch()
self.current_match_set_idx += 1
if self.current_match_set_idx == len( self.match_set_list ):
# no more items
QtGui.QDialog.accept(self)
else:
self.updateData()
def skipToNext( self ):
self.current_match_set_idx += 1
if self.current_match_set_idx == len( self.match_set_list ):
# no more items
QtGui.QDialog.reject(self)
else:
self.updateData()
def reject(self):
reply = QtGui.QMessageBox.question(self,
self.tr("Cancel Matching"),
self.tr("Are you sure you wish to cancel the matching process?"),
QtGui.QMessageBox.Yes, QtGui.QMessageBox.No )
if reply == QtGui.QMessageBox.No:
return
QtGui.QDialog.reject(self)
def saveMatch( self ):
match = self.currentMatch()
ca = self.current_match_set.ca
md = ca.readMetadata( self.style )
if md.isEmpty:
md = ca.metadataFromFilename()
# now get the particular issue data
cv_md = self.fetch_func( match )
if cv_md is None:
QtGui.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to ComicVine to get issue details!"))
return
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
md.overlay( cv_md )
success = ca.writeMetadata( md, self.style )
ca.loadCache( [ MetaDataStyle.CBI, MetaDataStyle.CIX ] )
QtGui.QApplication.restoreOverrideCursor()
if not success:
QtGui.QMessageBox.warning(self, self.tr("Write Error"), self.tr("Saving the tags to the archive seemed to fail!"))

View File

@ -0,0 +1,69 @@
"""
A PyQT4 dialog to show ID log and progress
"""
"""
Copyright 2012-2014 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 sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
from coverimagewidget import CoverImageWidget
import utils
class AutoTagProgressWindow(QtGui.QDialog):
def __init__(self, parent):
super(AutoTagProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('autotagprogresswindow.ui' ), self)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.DataMode, False )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.testCoverWidget = CoverImageWidget( self.testCoverContainer, CoverImageWidget.DataMode, False )
gridlayout = QtGui.QGridLayout( self.testCoverContainer )
gridlayout.addWidget( self.testCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.isdone = False
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
utils.reduceWidgetFontSize( self.textEdit )
def setArchiveImage( self, img_data):
self.setCoverImage( img_data, self.archiveCoverWidget)
def setTestImage( self, img_data):
self.setCoverImage( img_data, self.testCoverWidget)
def setCoverImage( self, img_data , widget):
widget.setImageData( img_data )
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def reject(self):
QtGui.QDialog.reject(self)
self.isdone = True

View File

@ -0,0 +1,127 @@
"""
A PyQT4 dialog to confirm and set options for auto-tag
"""
"""
Copyright 2012-2014 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.
"""
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from settingswindow import SettingsWindow
from filerenamer import FileRenamer
import os
import utils
class AutoTagStartWindow(QtGui.QDialog):
def __init__( self, parent, settings, msg ):
super(AutoTagStartWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('autotagstartwindow.ui' ), self)
self.label.setText( msg )
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint )
self.settings = settings
self.cbxSaveOnLowConfidence.setCheckState( QtCore.Qt.Unchecked )
self.cbxDontUseYear.setCheckState( QtCore.Qt.Unchecked )
self.cbxAssumeIssueOne.setCheckState( QtCore.Qt.Unchecked )
self.cbxIgnoreLeadingDigitsInFilename.setCheckState( QtCore.Qt.Unchecked )
self.cbxRemoveAfterSuccess.setCheckState( QtCore.Qt.Unchecked )
self.cbxSpecifySearchString.setCheckState( QtCore.Qt.Unchecked )
self.leNameLengthMatchTolerance.setText( str(self.settings.id_length_delta_thresh) )
self.leSearchString.setEnabled( False )
if self.settings.save_on_low_confidence:
self.cbxSaveOnLowConfidence.setCheckState( QtCore.Qt.Checked)
if self.settings.dont_use_year_when_identifying:
self.cbxDontUseYear.setCheckState( QtCore.Qt.Checked)
if self.settings.assume_1_if_no_issue_num:
self.cbxAssumeIssueOne.setCheckState( QtCore.Qt.Checked)
if self.settings.ignore_leading_numbers_in_filename:
self.cbxIgnoreLeadingDigitsInFilename.setCheckState( QtCore.Qt.Checked)
if self.settings.remove_archive_after_successful_match:
self.cbxRemoveAfterSuccess.setCheckState( QtCore.Qt.Checked)
if self.settings.wait_and_retry_on_rate_limit:
self.cbxWaitForRateLimit.setCheckState( QtCore.Qt.Checked)
nlmtTip = (
""" <html>The <b>Name Length Match Tolerance</b> is for eliminating automatic
search matches that are too long compared to your series name search. The higher
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too low, and only the very closest lexical matches will be
explored.</html>""" )
self.leNameLengthMatchTolerance.setToolTip(nlmtTip)
ssTip = (
"""<html>
The <b>series search string</b> specifies the search string to be used for all selected archives.
Use this when trying to match archives with hard-to-parse or incorrect filenames. All archives selected
should be from the same series.
</html>"""
)
self.leSearchString.setToolTip(ssTip)
self.cbxSpecifySearchString.setToolTip(ssTip)
validator = QtGui.QIntValidator(0, 99, self)
self.leNameLengthMatchTolerance.setValidator(validator)
self.cbxSpecifySearchString.stateChanged.connect(self.searchStringToggle)
self.autoSaveOnLow = False
self.dontUseYear = False
self.assumeIssueOne = False
self.ignoreLeadingDigitsInFilename = False
self.removeAfterSuccess = False
self.waitAndRetryOnRateLimit = False
self.searchString = None
self.nameLengthMatchTolerance = self.settings.id_length_delta_thresh
def searchStringToggle(self):
enable = self.cbxSpecifySearchString.isChecked()
self.leSearchString.setEnabled( enable )
def accept( self ):
QtGui.QDialog.accept(self)
self.autoSaveOnLow = self.cbxSaveOnLowConfidence.isChecked()
self.dontUseYear = self.cbxDontUseYear.isChecked()
self.assumeIssueOne = self.cbxAssumeIssueOne.isChecked()
self.ignoreLeadingDigitsInFilename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.removeAfterSuccess = self.cbxRemoveAfterSuccess.isChecked()
self.nameLengthMatchTolerance = int(self.leNameLengthMatchTolerance.text())
self.waitAndRetryOnRateLimit = self.cbxWaitForRateLimit.isChecked()
#persist some settings
self.settings.save_on_low_confidence = self.autoSaveOnLow
self.settings.dont_use_year_when_identifying = self.dontUseYear
self.settings.assume_1_if_no_issue_num = self.assumeIssueOne
self.settings.ignore_leading_numbers_in_filename = self.ignoreLeadingDigitsInFilename
self.settings.remove_archive_after_successful_match = self.removeAfterSuccess
self.settings.wait_and_retry_on_rate_limit = self.waitAndRetryOnRateLimit
if self.cbxSpecifySearchString.isChecked():
self.searchString = unicode(self.leSearchString.text())
if len(self.searchString) == 0:
self.searchString = None

View File

@ -0,0 +1,102 @@
"""
Class to manage modifying metadata specifically for CBL/CBI
"""
"""
Copyright 2012-2014 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 os
import utils
class CBLTransformer:
def __init__( self, metadata, settings ):
self.metadata = metadata
self.settings = settings
def apply( self ):
# helper funcs
def append_to_tags_if_unique( item ):
if item.lower() not in (tag.lower() for tag in self.metadata.tags):
self.metadata.tags.append( item )
def add_string_list_to_tags( str_list ):
if str_list is not None and str_list != "":
items = [ s.strip() for s in str_list.split(',') ]
for item in items:
append_to_tags_if_unique( item )
if self.settings.assume_lone_credit_is_primary:
# helper
def setLonePrimary( role_list ):
lone_credit = None
count = 0
for c in self.metadata.credits:
if c['role'].lower() in role_list:
count += 1
lone_credit = c
if count > 1:
lone_credit = None
break
if lone_credit is not None:
lone_credit['primary'] = True
return lone_credit, count
#need to loop three times, once for 'writer', 'artist', and then 'penciler' if no artist
setLonePrimary( ['writer'] )
c, count = setLonePrimary( ['artist'] )
if c is None and count == 0:
c, count = setLonePrimary( ['penciler', 'penciller'] )
if c is not None:
c['primary'] = False
self.metadata.addCredit( c['person'], 'Artist', True )
if self.settings.copy_characters_to_tags:
add_string_list_to_tags( self.metadata.characters )
if self.settings.copy_teams_to_tags:
add_string_list_to_tags( self.metadata.teams )
if self.settings.copy_locations_to_tags:
add_string_list_to_tags( self.metadata.locations )
if self.settings.copy_storyarcs_to_tags:
add_string_list_to_tags( self.metadata.storyArc )
if self.settings.copy_notes_to_comments:
if self.metadata.notes is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.notes not in self.metadata.comments:
self.metadata.comments += self.metadata.notes
if self.settings.copy_weblink_to_comments:
if self.metadata.webLink is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.webLink not in self.metadata.comments:
self.metadata.comments += self.metadata.webLink
return self.metadata

545
lib/comictaggerlib/cli.py Normal file
View File

@ -0,0 +1,545 @@
#!/usr/bin/python
"""
Comic tagger CLI functions
"""
"""
Copyright 2013 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 sys
import signal
import os
import traceback
import time
from pprint import pprint
import json
import platform
import locale
filename_encoding = sys.getfilesystemencoding()
from settings import ComicTaggerSettings
from options import Options
from comicarchive import ComicArchive, MetaDataStyle
from issueidentifier import IssueIdentifier
from genericmetadata import GenericMetadata
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from filerenamer import FileRenamer
from cbltransformer import CBLTransformer
import utils
import codecs
class MultipleMatch():
def __init__( self, filename, match_list):
self.filename = filename
self.matches = match_list
class OnlineMatchResults():
def __init__(self):
self.goodMatches = []
self.noMatches = []
self.multipleMatches = []
self.lowConfidenceMatches = []
self.writeFailures = []
self.fetchDataFailures = []
#-----------------------------
def actual_issue_data_fetch( match, settings, opts ):
# now get the particular issue data
try:
comicVine = ComicVineTalker()
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
cv_md = comicVine.fetchIssueData( match['volume_id'], match['issue_number'], settings )
except ComicVineTalkerException:
print >> sys.stderr, "Network error while getting issue details. Save aborted"
return None
if settings.apply_cbl_transform_on_cv_import:
cv_md = CBLTransformer( cv_md, settings ).apply()
return cv_md
def actual_metadata_save( ca, opts, md ):
if not opts.dryrun:
# write out the new data
if not ca.writeMetadata( md, opts.data_style ):
print >> sys.stderr,"The tag save seemed to fail!"
return False
else:
pass
#print >> sys.stderr,"Save complete."
else:
if opts.terse:
print >> sys.stderr,"dry-run option was set, so nothing was written"
else:
print >> sys.stderr,"dry-run option was set, so nothing was written, but here is the final set of tags:"
print u"{0}".format(md)
return True
def display_match_set_for_choice( label, match_set, opts, settings ):
print u"{0} -- {1}:".format(match_set.filename, label )
# sort match list by year
match_set.matches.sort(key=lambda k: k['year'])
for (counter,m) in enumerate(match_set.matches):
counter += 1
print u" {0}. {1} #{2} [{3}] ({4}/{5}) - {6}".format(counter,
m['series'],
m['issue_number'],
m['publisher'],
m['month'],
m['year'],
m['issue_title'])
if opts.interactive:
while True:
i = raw_input("Choose a match #, or 's' to skip: ")
if (i.isdigit() and int(i) in range(1,len(match_set.matches)+1)) or i == 's':
break
if i != 's':
i = int(i) - 1
# save the data!
# we know at this point, that the file is all good to go
ca = ComicArchive( match_set.filename, settings.rar_exe_path )
md = create_local_metadata( opts, ca, ca.hasMetadata(opts.data_style) )
cv_md = actual_issue_data_fetch(match_set.matches[int(i)], settings, opts)
md.overlay( cv_md )
actual_metadata_save( ca, opts, md )
def post_process_matches( match_results, opts, settings ):
# now go through the match results
if opts.show_save_summary:
if len( match_results.goodMatches ) > 0:
print "\nSuccessful matches:"
print "------------------"
for f in match_results.goodMatches:
print f
if len( match_results.noMatches ) > 0:
print "\nNo matches:"
print "------------------"
for f in match_results.noMatches:
print f
if len( match_results.writeFailures ) > 0:
print "\nFile Write Failures:"
print "------------------"
for f in match_results.writeFailures:
print f
if len( match_results.fetchDataFailures ) > 0:
print "\nNetwork Data Fetch Failures:"
print "------------------"
for f in match_results.fetchDataFailures:
print f
if not opts.show_save_summary and not opts.interactive:
#just quit if we're not interactive or showing the summary
return
if len( match_results.multipleMatches ) > 0:
print "\nArchives with multiple high-confidence matches:"
print "------------------"
for match_set in match_results.multipleMatches:
display_match_set_for_choice( "Multiple high-confidence matches", match_set, opts, settings )
if len( match_results.lowConfidenceMatches ) > 0:
print "\nArchives with low-confidence matches:"
print "------------------"
for match_set in match_results.lowConfidenceMatches:
if len( match_set.matches) == 1:
label = "Single low-confidence match"
else:
label = "Multiple low-confidence matches"
display_match_set_for_choice( label, match_set, opts, settings )
def cli_mode( opts, settings ):
if len( opts.file_list ) < 1:
print >> sys.stderr,"You must specify at least one filename. Use the -h option for more info"
return
match_results = OnlineMatchResults()
for f in opts.file_list:
if type(f) == str:
f = f.decode(filename_encoding, 'replace')
process_file_cli( f, opts, settings, match_results )
sys.stdout.flush()
post_process_matches( match_results, opts, settings )
def create_local_metadata( opts, ca, has_desired_tags ):
md = GenericMetadata()
md.setDefaultPageList( ca.getNumberOfPages() )
if has_desired_tags:
md = ca.readMetadata( opts.data_style )
# now, overlay the parsed filename info
if opts.parse_filename:
md.overlay( ca.metadataFromFilename() )
# finally, use explicit stuff
if opts.metadata is not None:
md.overlay( opts.metadata )
return md
def process_file_cli( filename, opts, settings, match_results ):
batch_mode = len( opts.file_list ) > 1
ca = ComicArchive(filename, settings.rar_exe_path)
if not os.path.lexists( filename ):
print >> sys.stderr,"Cannot find "+ filename
return
if not ca.seemsToBeAComicArchive():
print >> sys.stderr,"Sorry, but "+ filename + " is not a comic archive!"
return
#if not ca.isWritableForStyle( opts.data_style ) and ( opts.delete_tags or opts.save_tags or opts.rename_file ):
if not ca.isWritable( ) and ( opts.delete_tags or opts.copy_tags or opts.save_tags or opts.rename_file ):
print >> sys.stderr,"This archive is not writable for that tag type"
return
has = [ False, False, False ]
if ca.hasCIX(): has[ MetaDataStyle.CIX ] = True
if ca.hasCBI(): has[ MetaDataStyle.CBI ] = True
if ca.hasCoMet(): has[ MetaDataStyle.COMET ] = True
if opts.print_tags:
if opts.data_style is None:
page_count = ca.getNumberOfPages()
brief = ""
if batch_mode:
brief = u"{0}: ".format(filename)
if ca.isZip(): brief += "ZIP archive "
elif ca.isRar(): brief += "RAR archive "
elif ca.isFolder(): brief += "Folder archive "
brief += "({0: >3} pages)".format(page_count)
brief += " tags:[ "
if not ( has[ MetaDataStyle.CBI ] or has[ MetaDataStyle.CIX ] or has[ MetaDataStyle.COMET ] ):
brief += "none "
else:
if has[ MetaDataStyle.CBI ]: brief += "CBL "
if has[ MetaDataStyle.CIX ]: brief += "CR "
if has[ MetaDataStyle.COMET ]: brief += "CoMet "
brief += "]"
print brief
if opts.terse:
return
print
if opts.data_style is None or opts.data_style == MetaDataStyle.CIX:
if has[ MetaDataStyle.CIX ]:
print "------ComicRack tags--------"
if opts.raw:
print u"{0}".format(unicode(ca.readRawCIX(), errors='ignore'))
else:
print u"{0}".format(ca.readCIX())
if opts.data_style is None or opts.data_style == MetaDataStyle.CBI:
if has[ MetaDataStyle.CBI ]:
print "------ComicBookLover tags--------"
if opts.raw:
pprint(json.loads(ca.readRawCBI()))
else:
print u"{0}".format(ca.readCBI())
if opts.data_style is None or opts.data_style == MetaDataStyle.COMET:
if has[ MetaDataStyle.COMET ]:
print "------CoMet tags--------"
if opts.raw:
print u"{0}".format(ca.readRawCoMet())
else:
print u"{0}".format(ca.readCoMet())
elif opts.delete_tags:
style_name = MetaDataStyle.name[ opts.data_style ]
if has[ opts.data_style ]:
if not opts.dryrun:
if not ca.removeMetadata( opts.data_style ):
print u"{0}: Tag removal seemed to fail!".format( filename )
else:
print u"{0}: Removed {1} tags.".format( filename, style_name )
else:
print u"{0}: dry-run. {1} tags not removed".format( filename, style_name )
else:
print u"{0}: This archive doesn't have {1} tags to remove.".format( filename, style_name )
elif opts.copy_tags:
dst_style_name = MetaDataStyle.name[ opts.data_style ]
if opts.no_overwrite and has[ opts.data_style ]:
print u"{0}: Already has {1} tags. Not overwriting.".format(filename, dst_style_name)
return
if opts.copy_source == opts.data_style:
print u"{0}: Destination and source are same: {1}. Nothing to do.".format(filename, dst_style_name)
return
src_style_name = MetaDataStyle.name[ opts.copy_source ]
if has[ opts.copy_source ]:
if not opts.dryrun:
md = ca.readMetadata( opts.copy_source )
if settings.apply_cbl_transform_on_bulk_operation and opts.data_style == MetaDataStyle.CBI:
md = CBLTransformer( md, settings ).apply()
if not ca.writeMetadata( md, opts.data_style ):
print u"{0}: Tag copy seemed to fail!".format( filename )
else:
print u"{0}: Copied {1} tags to {2} .".format( filename, src_style_name, dst_style_name )
else:
print u"{0}: dry-run. {1} tags not copied".format( filename, src_style_name )
else:
print u"{0}: This archive doesn't have {1} tags to copy.".format( filename, src_style_name )
elif opts.save_tags:
if opts.no_overwrite and has[ opts.data_style ]:
print u"{0}: Already has {1} tags. Not overwriting.".format(filename, MetaDataStyle.name[ opts.data_style ])
return
if batch_mode:
print u"Processing {0}...".format(filename)
md = create_local_metadata( opts, ca, has[ opts.data_style ] )
if md.issue is None or md.issue == "":
if opts.assume_issue_is_one_if_not_set:
md.issue = "1"
# now, search online
if opts.search_online:
if opts.issue_id is not None:
# we were given the actual ID to search with
try:
comicVine = ComicVineTalker()
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
cv_md = comicVine.fetchIssueDataByIssueID( opts.issue_id, settings )
except ComicVineTalkerException:
print >> sys.stderr,"Network error while getting issue details. Save aborted"
match_results.fetchDataFailures.append(filename)
return
if cv_md is None:
print >> sys.stderr,"No match for ID {0} was found.".format(opts.issue_id)
match_results.noMatches.append(filename)
return
if settings.apply_cbl_transform_on_cv_import:
cv_md = CBLTransformer( cv_md, settings ).apply()
else:
ii = IssueIdentifier( ca, settings )
if md is None or md.isEmpty:
print >> sys.stderr,"No metadata given to search online with!"
match_results.noMatches.append(filename)
return
def myoutput( text ):
if opts.verbose:
IssueIdentifier.defaultWriteOutput( text )
# use our overlayed MD struct to search
ii.setAdditionalMetadata( md )
ii.onlyUseAdditionalMetaData = True
ii.waitAndRetryOnRateLimit = opts.wait_and_retry_on_rate_limit
ii.setOutputFunction( myoutput )
ii.cover_page_index = md.getCoverPageIndexList()[0]
matches = ii.search()
result = ii.search_result
found_match = False
choices = False
low_confidence = False
if result == ii.ResultNoMatches:
pass
elif result == ii.ResultFoundMatchButBadCoverScore:
low_confidence = True
found_match = True
elif result == ii.ResultFoundMatchButNotFirstPage :
found_match = True
elif result == ii.ResultMultipleMatchesWithBadImageScores:
low_confidence = True
choices = True
elif result == ii.ResultOneGoodMatch:
found_match = True
elif result == ii.ResultMultipleGoodMatches:
choices = True
if choices:
if low_confidence:
print >> sys.stderr,"Online search: Multiple low confidence matches. Save aborted"
match_results.lowConfidenceMatches.append(MultipleMatch(filename,matches))
return
else:
print >> sys.stderr,"Online search: Multiple good matches. Save aborted"
match_results.multipleMatches.append(MultipleMatch(filename,matches))
return
if low_confidence and opts.abortOnLowConfidence:
print >> sys.stderr,"Online search: Low confidence match. Save aborted"
match_results.lowConfidenceMatches.append(MultipleMatch(filename,matches))
return
if not found_match:
print >> sys.stderr,"Online search: No match found. Save aborted"
match_results.noMatches.append(filename)
return
# we got here, so we have a single match
# now get the particular issue data
cv_md = actual_issue_data_fetch(matches[0], settings, opts)
if cv_md is None:
match_results.fetchDataFailures.append(filename)
return
md.overlay( cv_md )
# ok, done building our metadata. time to save
if not actual_metadata_save( ca, opts, md ):
match_results.writeFailures.append(filename)
else:
match_results.goodMatches.append(filename)
elif opts.rename_file:
msg_hdr = ""
if batch_mode:
msg_hdr = u"{0}: ".format(filename)
if opts.data_style is not None:
use_tags = has[ opts.data_style ]
else:
use_tags = False
md = create_local_metadata( opts, ca, use_tags )
if md.series is None:
print >> sys.stderr, msg_hdr + "Can't rename without series name"
return
new_ext = None # default
if settings.rename_extension_based_on_archive:
if ca.isZip():
new_ext = ".cbz"
elif ca.isRar():
new_ext = ".cbr"
renamer = FileRenamer( md )
renamer.setTemplate( settings.rename_template )
renamer.setIssueZeroPadding( settings.rename_issue_number_padding )
renamer.setSmartCleanup( settings.rename_use_smart_string_cleanup )
new_name = renamer.determineName( filename, ext=new_ext )
if new_name == os.path.basename(filename):
print >> sys.stderr, msg_hdr + "Filename is already good!"
return
folder = os.path.dirname( os.path.abspath( filename ) )
new_abs_path = utils.unique_file( os.path.join( folder, new_name ) )
suffix = ""
if not opts.dryrun:
# rename the file
os.rename( filename, new_abs_path )
else:
suffix = " (dry-run, no change)"
print u"renamed '{0}' -> '{1}' {2}".format(os.path.basename(filename), new_name, suffix)
elif opts.export_to_zip:
msg_hdr = ""
if batch_mode:
msg_hdr = u"{0}: ".format(filename)
if not ca.isRar():
print >> sys.stderr, msg_hdr + "Archive is not a RAR."
return
rar_file = os.path.abspath( os.path.abspath( filename ) )
new_file = os.path.splitext(rar_file)[0] + ".cbz"
if opts.abort_export_on_conflict and os.path.lexists( new_file ):
print msg_hdr + "{0} already exists in the that folder.".format(os.path.split(new_file)[1])
return
new_file = utils.unique_file( os.path.join( new_file ) )
delete_success = False
export_success = False
if not opts.dryrun:
if ca.exportAsZip( new_file ):
export_success = True
if opts.delete_rar_after_export:
try:
os.unlink( rar_file )
except:
print >> sys.stderr, msg_hdr + "Error deleting original RAR after export"
delete_success = False
else:
delete_success = True
else:
# last export failed, so remove the zip, if it exists
if os.path.lexists( new_file ):
os.remove( new_file )
else:
msg = msg_hdr + u"Dry-run: Would try to create {0}".format(os.path.split(new_file)[1])
if opts.delete_rar_after_export:
msg += u" and delete orginal."
print msg
return
msg = msg_hdr
if export_success:
msg += u"Archive exported successfully to: {0}".format( os.path.split(new_file)[1] )
if opts.delete_rar_after_export and delete_success:
msg += u" (Original deleted) "
else:
msg += u"Archive failed to export!"
print msg

260
lib/comictaggerlib/comet.py Normal file
View File

@ -0,0 +1,260 @@
"""
A python class to encapsulate CoMet data
"""
"""
Copyright 2012-2014 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.
"""
from datetime import datetime
import zipfile
from pprint import pprint
import xml.etree.ElementTree as ET
from genericmetadata import GenericMetadata
import utils
class CoMet:
writer_synonyms = ['writer', 'plotter', 'scripter']
penciller_synonyms = [ 'artist', 'penciller', 'penciler', 'breakdowns' ]
inker_synonyms = [ 'inker', 'artist', 'finishes' ]
colorist_synonyms = [ 'colorist', 'colourist', 'colorer', 'colourer' ]
letterer_synonyms = [ 'letterer']
cover_synonyms = [ 'cover', 'covers', 'coverartist', 'cover artist' ]
editor_synonyms = [ 'editor']
def metadataFromString( self, string ):
tree = ET.ElementTree(ET.fromstring( string ))
return self.convertXMLToMetadata( tree )
def stringFromMetadata( self, metadata ):
header = '<?xml version="1.0" encoding="UTF-8"?>\n'
tree = self.convertMetadataToXML( self, metadata )
return header + ET.tostring(tree.getroot())
def indent( self, elem, level=0 ):
# for making the XML output readable
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
self.indent( elem, level+1 )
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def convertMetadataToXML( self, filename, metadata ):
#shorthand for the metadata
md = metadata
# build a tree structure
root = ET.Element("comet")
root.attrib['xmlns:comet'] = "http://www.denvog.com/comet/"
root.attrib['xmlns:xsi'] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib['xsi:schemaLocation'] = "http://www.denvog.com http://www.denvog.com/comet/comet.xsd"
#helper func
def assign( comet_entry, md_entry):
if md_entry is not None:
ET.SubElement(root, comet_entry).text = u"{0}".format(md_entry)
# title is manditory
if md.title is None:
md.title = ""
assign( 'title', md.title )
assign( 'series', md.series )
assign( 'issue', md.issue ) #must be int??
assign( 'volume', md.volume )
assign( 'description', md.comments )
assign( 'publisher', md.publisher )
assign( 'pages', md.pageCount )
assign( 'format', md.format )
assign( 'language', md.language )
assign( 'rating', md.maturityRating )
assign( 'price', md.price )
assign( 'isVersionOf', md.isVersionOf )
assign( 'rights', md.rights )
assign( 'identifier', md.identifier )
assign( 'lastMark', md.lastMark )
assign( 'genre', md.genre ) # TODO repeatable
if md.characters is not None:
char_list = [ c.strip() for c in md.characters.split(',') ]
for c in char_list:
assign( 'character', c )
if md.manga is not None and md.manga == "YesAndRightToLeft":
assign( 'readingDirection', "rtl")
date_str = ""
if md.year is not None:
date_str = str(md.year).zfill(4)
if md.month is not None:
date_str += "-" + str(md.month).zfill(2)
assign( 'date', date_str )
assign( 'coverImage', md.coverImage )
# need to specially process the credits, since they are structured differently than CIX
credit_writer_list = list()
credit_penciller_list = list()
credit_inker_list = list()
credit_colorist_list = list()
credit_letterer_list = list()
credit_cover_list = list()
credit_editor_list = list()
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit['role'].lower() in set( self.writer_synonyms ):
ET.SubElement(root, 'writer').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.penciller_synonyms ):
ET.SubElement(root, 'penciller').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.inker_synonyms ):
ET.SubElement(root, 'inker').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.colorist_synonyms ):
ET.SubElement(root, 'colorist').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.letterer_synonyms ):
ET.SubElement(root, 'letterer').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.cover_synonyms ):
ET.SubElement(root, 'coverDesigner').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.editor_synonyms ):
ET.SubElement(root, 'editor').text = u"{0}".format(credit['person'])
# self pretty-print
self.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convertXMLToMetadata( self, tree ):
root = tree.getroot()
if root.tag != 'comet':
raise 1
return None
metadata = GenericMetadata()
md = metadata
# Helper function
def xlate( tag ):
node = root.find( tag )
if node is not None:
return node.text
else:
return None
md.series = xlate( 'series' )
md.title = xlate( 'title' )
md.issue = xlate( 'issue' )
md.volume = xlate( 'volume' )
md.comments = xlate( 'description' )
md.publisher = xlate( 'publisher' )
md.language = xlate( 'language' )
md.format = xlate( 'format' )
md.pageCount = xlate( 'pages' )
md.maturityRating = xlate( 'rating' )
md.price = xlate( 'price' )
md.isVersionOf = xlate( 'isVersionOf' )
md.rights = xlate( 'rights' )
md.identifier = xlate( 'identifier' )
md.lastMark = xlate( 'lastMark' )
md.genre = xlate( 'genre' ) # TODO - repeatable field
date = xlate( 'date' )
if date is not None:
parts = date.split('-')
if len( parts) > 0:
md.year = parts[0]
if len( parts) > 1:
md.month = parts[1]
md.coverImage = xlate( 'coverImage' )
readingDirection = xlate( 'readingDirection' )
if readingDirection is not None and readingDirection == "rtl":
md.manga = "YesAndRightToLeft"
# loop for character tags
char_list = []
for n in root:
if n.tag == 'character':
char_list.append(n.text.strip())
md.characters = utils.listToString( char_list )
# Now extract the credit info
for n in root:
if ( n.tag == 'writer' or
n.tag == 'penciller' or
n.tag == 'inker' or
n.tag == 'colorist' or
n.tag == 'letterer' or
n.tag == 'editor'
):
metadata.addCredit( n.text.strip(), n.tag.title() )
if n.tag == 'coverDesigner':
metadata.addCredit( n.text.strip(), "Cover" )
metadata.isEmpty = False
return metadata
#verify that the string actually contains CoMet data in XML format
def validateString( self, string ):
try:
tree = ET.ElementTree(ET.fromstring( string ))
root = tree.getroot()
if root.tag != 'comet':
raise Exception
except:
return False
return True
def writeToExternalFile( self, filename, metadata ):
tree = self.convertMetadataToXML( self, metadata )
#ET.dump(tree)
tree.write(filename, encoding='utf-8')
def readFromExternalFile( self, filename ):
tree = ET.parse( filename )
return self.convertXMLToMetadata( tree )

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
"""
A python class to encapsulate the ComicBookInfo data
"""
"""
Copyright 2012-2014 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 json
from datetime import datetime
import zipfile
from genericmetadata import GenericMetadata
import utils
import ctversion
class ComicBookInfo:
def metadataFromString( self, string ):
cbi_container = json.loads( unicode(string, 'utf-8') )
metadata = GenericMetadata()
cbi = cbi_container[ 'ComicBookInfo/1.0' ]
#helper func
# If item is not in CBI, return None
def xlate( cbi_entry):
if cbi_entry in cbi:
return cbi[cbi_entry]
else:
return None
metadata.series = xlate( 'series' )
metadata.title = xlate( 'title' )
metadata.issue = xlate( 'issue' )
metadata.publisher = xlate( 'publisher' )
metadata.month = xlate( 'publicationMonth' )
metadata.year = xlate( 'publicationYear' )
metadata.issueCount = xlate( 'numberOfIssues' )
metadata.comments = xlate( 'comments' )
metadata.credits = xlate( 'credits' )
metadata.genre = xlate( 'genre' )
metadata.volume = xlate( 'volume' )
metadata.volumeCount = xlate( 'numberOfVolumes' )
metadata.language = xlate( 'language' )
metadata.country = xlate( 'country' )
metadata.criticalRating = xlate( 'rating' )
metadata.tags = xlate( 'tags' )
# make sure credits and tags are at least empty lists and not None
if metadata.credits is None:
metadata.credits = []
if metadata.tags is None:
metadata.tags = []
#need to massage the language string to be ISO
if metadata.language is not None:
# reverse look-up
pattern = metadata.language
metadata.language = None
for key in utils.getLanguageDict():
if utils.getLanguageDict()[ key ] == pattern.encode('utf-8'):
metadata.language = key
break
metadata.isEmpty = False
return metadata
def stringFromMetadata( self, metadata ):
cbi_container = self.createJSONDictionary( metadata )
return json.dumps( cbi_container )
#verify that the string actually contains CBI data in JSON format
def validateString( self, string ):
try:
cbi_container = json.loads( string )
except:
return False
return ( 'ComicBookInfo/1.0' in cbi_container )
def createJSONDictionary( self, metadata ):
# Create the dictionary that we will convert to JSON text
cbi = dict()
cbi_container = {'appID' : 'ComicTagger/' + ctversion.version,
'lastModified' : str(datetime.now()),
'ComicBookInfo/1.0' : cbi }
#helper func
def assign( cbi_entry, md_entry):
if md_entry is not None:
cbi[cbi_entry] = md_entry
#helper func
def toInt(s):
i = None
if type(s) in [ str, unicode, int ]:
try:
i = int(s)
except ValueError:
pass
return i
assign( 'series', metadata.series )
assign( 'title', metadata.title )
assign( 'issue', metadata.issue )
assign( 'publisher', metadata.publisher )
assign( 'publicationMonth', toInt(metadata.month) )
assign( 'publicationYear', toInt(metadata.year) )
assign( 'numberOfIssues', toInt(metadata.issueCount) )
assign( 'comments', metadata.comments )
assign( 'genre', metadata.genre )
assign( 'volume', toInt(metadata.volume) )
assign( 'numberOfVolumes', toInt(metadata.volumeCount) )
assign( 'language', utils.getLanguageFromISO(metadata.language) )
assign( 'country', metadata.country )
assign( 'rating', metadata.criticalRating )
assign( 'credits', metadata.credits )
assign( 'tags', metadata.tags )
return cbi_container
def writeToExternalFile( self, filename, metadata ):
cbi_container = self.createJSONDictionary(metadata)
f = open(filename, 'w')
f.write(json.dumps(cbi_container, indent=4))
f.close

View File

@ -0,0 +1,293 @@
"""
A python class to encapsulate ComicRack's ComicInfo.xml data
"""
"""
Copyright 2012-2014 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.
"""
from datetime import datetime
import zipfile
from pprint import pprint
import xml.etree.ElementTree as ET
from genericmetadata import GenericMetadata
import utils
class ComicInfoXml:
writer_synonyms = ['writer', 'plotter', 'scripter']
penciller_synonyms = [ 'artist', 'penciller', 'penciler', 'breakdowns' ]
inker_synonyms = [ 'inker', 'artist', 'finishes' ]
colorist_synonyms = [ 'colorist', 'colourist', 'colorer', 'colourer' ]
letterer_synonyms = [ 'letterer']
cover_synonyms = [ 'cover', 'covers', 'coverartist', 'cover artist' ]
editor_synonyms = [ 'editor']
def getParseableCredits( self ):
parsable_credits = []
parsable_credits.extend( self.writer_synonyms )
parsable_credits.extend( self.penciller_synonyms )
parsable_credits.extend( self.inker_synonyms )
parsable_credits.extend( self.colorist_synonyms )
parsable_credits.extend( self.letterer_synonyms )
parsable_credits.extend( self.cover_synonyms )
parsable_credits.extend( self.editor_synonyms )
return parsable_credits
def metadataFromString( self, string ):
tree = ET.ElementTree(ET.fromstring( string ))
return self.convertXMLToMetadata( tree )
def stringFromMetadata( self, metadata ):
header = '<?xml version="1.0"?>\n'
tree = self.convertMetadataToXML( self, metadata )
return header + ET.tostring(tree.getroot())
def indent( self, elem, level=0 ):
# for making the XML output readable
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
self.indent( elem, level+1 )
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def convertMetadataToXML( self, filename, metadata ):
#shorthand for the metadata
md = metadata
# build a tree structure
root = ET.Element("ComicInfo")
root.attrib['xmlns:xsi']="http://www.w3.org/2001/XMLSchema-instance"
root.attrib['xmlns:xsd']="http://www.w3.org/2001/XMLSchema"
#helper func
def assign( cix_entry, md_entry):
if md_entry is not None:
ET.SubElement(root, cix_entry).text = u"{0}".format(md_entry)
assign( 'Title', md.title )
assign( 'Series', md.series )
assign( 'Number', md.issue )
assign( 'Count', md.issueCount )
assign( 'Volume', md.volume )
assign( 'AlternateSeries', md.alternateSeries )
assign( 'AlternateNumber', md.alternateNumber )
assign( 'StoryArc', md.storyArc )
assign( 'SeriesGroup', md.seriesGroup )
assign( 'AlternateCount', md.alternateCount )
assign( 'Summary', md.comments )
assign( 'Notes', md.notes )
assign( 'Year', md.year )
assign( 'Month', md.month )
assign( 'Day', md.day )
# need to specially process the credits, since they are structured differently than CIX
credit_writer_list = list()
credit_penciller_list = list()
credit_inker_list = list()
credit_colorist_list = list()
credit_letterer_list = list()
credit_cover_list = list()
credit_editor_list = list()
# first, loop thru credits, and build a list for each role that CIX supports
for credit in metadata.credits:
if credit['role'].lower() in set( self.writer_synonyms ):
credit_writer_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.penciller_synonyms ):
credit_penciller_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.inker_synonyms ):
credit_inker_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.colorist_synonyms ):
credit_colorist_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.letterer_synonyms ):
credit_letterer_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.cover_synonyms ):
credit_cover_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.editor_synonyms ):
credit_editor_list.append(credit['person'].replace(",",""))
# second, convert each list to string, and add to XML struct
if len( credit_writer_list ) > 0:
node = ET.SubElement(root, 'Writer')
node.text = utils.listToString( credit_writer_list )
if len( credit_penciller_list ) > 0:
node = ET.SubElement(root, 'Penciller')
node.text = utils.listToString( credit_penciller_list )
if len( credit_inker_list ) > 0:
node = ET.SubElement(root, 'Inker')
node.text = utils.listToString( credit_inker_list )
if len( credit_colorist_list ) > 0:
node = ET.SubElement(root, 'Colorist')
node.text = utils.listToString( credit_colorist_list )
if len( credit_letterer_list ) > 0:
node = ET.SubElement(root, 'Letterer')
node.text = utils.listToString( credit_letterer_list )
if len( credit_cover_list ) > 0:
node = ET.SubElement(root, 'CoverArtist')
node.text = utils.listToString( credit_cover_list )
if len( credit_editor_list ) > 0:
node = ET.SubElement(root, 'Editor')
node.text = utils.listToString( credit_editor_list )
assign( 'Publisher', md.publisher )
assign( 'Imprint', md.imprint )
assign( 'Genre', md.genre )
assign( 'Web', md.webLink )
assign( 'PageCount', md.pageCount )
assign( 'LanguageISO', md.language )
assign( 'Format', md.format )
assign( 'AgeRating', md.maturityRating )
if md.blackAndWhite is not None and md.blackAndWhite:
ET.SubElement(root, 'BlackAndWhite').text = "Yes"
assign( 'Manga', md.manga )
assign( 'Characters', md.characters )
assign( 'Teams', md.teams )
assign( 'Locations', md.locations )
assign( 'ScanInformation', md.scanInfo )
# loop and add the page entries under pages node
if len( md.pages ) > 0:
pages_node = ET.SubElement(root, 'Pages')
for page_dict in md.pages:
page_node = ET.SubElement(pages_node, 'Page')
page_node.attrib = page_dict
# self pretty-print
self.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convertXMLToMetadata( self, tree ):
root = tree.getroot()
if root.tag != 'ComicInfo':
raise 1
return None
metadata = GenericMetadata()
md = metadata
# Helper function
def xlate( tag ):
node = root.find( tag )
if node is not None:
return node.text
else:
return None
md.series = xlate( 'Series' )
md.title = xlate( 'Title' )
md.issue = xlate( 'Number' )
md.issueCount = xlate( 'Count' )
md.volume = xlate( 'Volume' )
md.alternateSeries = xlate( 'AlternateSeries' )
md.alternateNumber = xlate( 'AlternateNumber' )
md.alternateCount = xlate( 'AlternateCount' )
md.comments = xlate( 'Summary' )
md.notes = xlate( 'Notes' )
md.year = xlate( 'Year' )
md.month = xlate( 'Month' )
md.day = xlate( 'Day' )
md.publisher = xlate( 'Publisher' )
md.imprint = xlate( 'Imprint' )
md.genre = xlate( 'Genre' )
md.webLink = xlate( 'Web' )
md.language = xlate( 'LanguageISO' )
md.format = xlate( 'Format' )
md.manga = xlate( 'Manga' )
md.characters = xlate( 'Characters' )
md.teams = xlate( 'Teams' )
md.locations = xlate( 'Locations' )
md.pageCount = xlate( 'PageCount' )
md.scanInfo = xlate( 'ScanInformation' )
md.storyArc = xlate( 'StoryArc' )
md.seriesGroup = xlate( 'SeriesGroup' )
md.maturityRating = xlate( 'AgeRating' )
tmp = xlate( 'BlackAndWhite' )
md.blackAndWhite = False
if tmp is not None and tmp.lower() in [ "yes", "true", "1" ]:
md.blackAndWhite = True
# Now extract the credit info
for n in root:
if ( n.tag == 'Writer' or
n.tag == 'Penciller' or
n.tag == 'Inker' or
n.tag == 'Colorist' or
n.tag == 'Letterer' or
n.tag == 'Editor'
):
if n.text is not None:
for name in n.text.split(','):
metadata.addCredit( name.strip(), n.tag )
if n.tag == 'CoverArtist':
if n.text is not None:
for name in n.text.split(','):
metadata.addCredit( name.strip(), "Cover" )
# parse page data now
pages_node = root.find( "Pages" )
if pages_node is not None:
for page in pages_node:
metadata.pages.append( page.attrib )
#print page.attrib
metadata.isEmpty = False
return metadata
def writeToExternalFile( self, filename, metadata ):
tree = self.convertMetadataToXML( self, metadata )
#ET.dump(tree)
tree.write(filename, encoding='utf-8')
def readFromExternalFile( self, filename ):
tree = ET.parse( filename )
return self.convertXMLToMetadata( tree )

View File

@ -0,0 +1,459 @@
"""
A python class to manage caching of data from Comic Vine
"""
"""
Copyright 2012-2014 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.
"""
from pprint import pprint
import sqlite3 as lite
import sys
import os
import datetime
import ctversion
from settings import ComicTaggerSettings
import utils
class ComicVineCacher:
def __init__(self ):
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join( self.settings_folder, "cv_cache.db")
self.version_file = os.path.join( self.settings_folder, "cache_version.txt")
#verify that cache is from same version as this one
data = ""
try:
with open( self.version_file, 'rb' ) as f:
data = f.read()
f.close()
except:
pass
if data != ctversion.version:
self.clearCache()
if not os.path.exists( self.db_file ):
self.create_cache_db()
def clearCache( self ):
try:
os.unlink( self.db_file )
except:
pass
try:
os.unlink( self.version_file )
except:
pass
def create_cache_db( self ):
#create the version file
with open( self.version_file, 'w' ) as f:
f.write( ctversion.version )
# this will wipe out any existing version
open( self.db_file, 'w').close()
con = lite.connect( self.db_file )
# create tables
with con:
cur = con.cursor()
#name,id,start_year,publisher,image,description,count_of_issues
cur.execute("CREATE TABLE VolumeSearchCache(" +
"search_term TEXT," +
"id INT," +
"name TEXT," +
"start_year INT," +
"publisher TEXT," +
"count_of_issues INT," +
"image_url TEXT," +
"description TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')) ) "
)
cur.execute("CREATE TABLE Volumes(" +
"id INT," +
"name TEXT," +
"publisher TEXT," +
"count_of_issues INT," +
"start_year INT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (id) )"
)
cur.execute("CREATE TABLE AltCovers(" +
"issue_id INT," +
"url_list TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (issue_id) )"
)
cur.execute("CREATE TABLE Issues(" +
"id INT," +
"volume_id INT," +
"name TEXT," +
"issue_number TEXT," +
"super_url TEXT," +
"thumb_url TEXT," +
"cover_date TEXT," +
"site_detail_url TEXT," +
"description TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (id ) )"
)
def add_search_results( self, search_term, cv_search_results ):
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# remove all previous entries with this search term
cur.execute("DELETE FROM VolumeSearchCache WHERE search_term = ?", [ search_term.lower() ])
# now add in new results
for record in cv_search_results:
timestamp = datetime.datetime.now()
if record['publisher'] is None:
pub_name = ""
else:
pub_name = record['publisher']['name']
if record['image'] is None:
url = ""
else:
url = record['image']['super_url']
cur.execute("INSERT INTO VolumeSearchCache " +
"(search_term, id, name, start_year, publisher, count_of_issues, image_url, description ) " +
"VALUES( ?, ?, ?, ?, ?, ?, ?, ? )" ,
( search_term.lower(),
record['id'],
record['name'],
record['start_year'],
pub_name,
record['count_of_issues'],
url,
record['description'])
)
def get_search_results( self, search_term ):
results = list()
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# purge stale search results
a_day_ago = datetime.datetime.today()-datetime.timedelta(days=1)
cur.execute( "DELETE FROM VolumeSearchCache WHERE timestamp < ?", [ str(a_day_ago) ] )
# fetch
cur.execute("SELECT * FROM VolumeSearchCache WHERE search_term=?", [ search_term.lower() ] )
rows = cur.fetchall()
# now process the results
for record in rows:
result = dict()
result['id'] = record[1]
result['name'] = record[2]
result['start_year'] = record[3]
result['publisher'] = dict()
result['publisher']['name'] = record[4]
result['count_of_issues'] = record[5]
result['image'] = dict()
result['image']['super_url'] = record[6]
result['description'] = record[7]
results.append(result)
return results
def add_alt_covers( self, issue_id, url_list ):
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# remove all previous entries with this search term
cur.execute("DELETE FROM AltCovers WHERE issue_id = ?", [ issue_id ])
url_list_str = utils.listToString(url_list)
# now add in new record
cur.execute("INSERT INTO AltCovers " +
"(issue_id, url_list ) " +
"VALUES( ?, ? )" ,
( issue_id,
url_list_str)
)
def get_alt_covers( self, issue_id ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale issue info - probably issue data won't change much....
a_month_ago = datetime.datetime.today()-datetime.timedelta(days=30)
cur.execute( "DELETE FROM AltCovers WHERE timestamp < ?", [ str(a_month_ago) ] )
cur.execute("SELECT url_list FROM AltCovers WHERE issue_id=?", [ issue_id ])
row = cur.fetchone()
if row is None :
return None
else:
url_list_str = row[0]
if len(url_list_str) == 0:
return []
raw_list = url_list_str.split(",")
url_list = []
for item in raw_list:
url_list.append( str(item).strip())
return url_list
def add_volume_info( self, cv_volume_record ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
if cv_volume_record['publisher'] is None:
pub_name = ""
else:
pub_name = cv_volume_record['publisher']['name']
data = {
"name": cv_volume_record['name'],
"publisher": pub_name,
"count_of_issues": cv_volume_record['count_of_issues'],
"start_year": cv_volume_record['start_year'],
"timestamp": timestamp
}
self.upsert( cur, "volumes", "id", cv_volume_record['id'], data)
def add_volume_issues_info( self, volume_id, cv_volume_issues ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
# add in issues
for issue in cv_volume_issues:
data = {
"volume_id": volume_id,
"name": issue['name'],
"issue_number": issue['issue_number'],
"site_detail_url": issue['site_detail_url'],
"cover_date": issue['cover_date'],
"super_url": issue['image']['super_url'],
"thumb_url": issue['image']['thumb_url'],
"description": issue['description'],
"timestamp": timestamp
}
self.upsert( cur, "issues" , "id", issue['id'], data)
def get_volume_info( self, volume_id ):
result = None
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale volume info
a_week_ago = datetime.datetime.today()-datetime.timedelta(days=7)
cur.execute( "DELETE FROM Volumes WHERE timestamp < ?", [ str(a_week_ago) ] )
# fetch
cur.execute("SELECT id,name,publisher,count_of_issues,start_year FROM Volumes WHERE id = ?", [ volume_id ] )
row = cur.fetchone()
if row is None :
return result
result = dict()
#since ID is primary key, there is only one row
result['id'] = row[0]
result['name'] = row[1]
result['publisher'] = dict()
result['publisher']['name'] = row[2]
result['count_of_issues'] = row[3]
result['start_year'] = row[4]
result['issues'] = list()
return result
def get_volume_issues_info( self, volume_id ):
result = None
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale issue info - probably issue data won't change much....
a_week_ago = datetime.datetime.today()-datetime.timedelta(days=7)
cur.execute( "DELETE FROM Issues WHERE timestamp < ?", [ str(a_week_ago) ] )
# fetch
results = list()
cur.execute("SELECT id,name,issue_number,site_detail_url,cover_date,super_url,thumb_url,description FROM Issues WHERE volume_id = ?", [ volume_id ] )
rows = cur.fetchall()
# now process the results
for row in rows:
record = dict()
record['id'] = row[0]
record['name'] = row[1]
record['issue_number'] = row[2]
record['site_detail_url'] = row[3]
record['cover_date'] = row[4]
record['image'] = dict()
record['image']['super_url'] = row[5]
record['image']['thumb_url'] = row[6]
record['description'] = row[7]
results.append(record)
if len(results) == 0:
return None
return results
def add_issue_select_details( self, issue_id, image_url, thumb_image_url, cover_date, site_detail_url ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
timestamp = datetime.datetime.now()
data = {
"super_url": image_url,
"thumb_url": thumb_image_url,
"cover_date": cover_date,
"site_detail_url": site_detail_url,
"timestamp": timestamp
}
self.upsert( cur, "issues" , "id", issue_id, data)
def get_issue_select_details( self, issue_id ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
cur.execute("SELECT super_url,thumb_url,cover_date,site_detail_url FROM Issues WHERE id=?", [ issue_id ])
row = cur.fetchone()
details = dict()
if row is None or row[0] is None :
details['image_url'] = None
details['thumb_image_url'] = None
details['cover_date'] = None
details['site_detail_url'] = None
else:
details['image_url'] = row[0]
details['thumb_image_url'] = row[1]
details['cover_date'] = row[2]
details['site_detail_url'] = row[3]
return details
def upsert( self, cur, tablename, pkname, pkval, data):
"""
This does an insert if the given PK doesn't exist, and an update it if does
"""
# TODO - look into checking if UPDATE is needed
# TODO - should the cursor be created here, and not up the stack?
ins_count = len(data) + 1
keys = ""
vals = list()
ins_slots = ""
set_slots = ""
for key in data:
if keys != "":
keys += ", "
if ins_slots != "":
ins_slots += ", "
if set_slots != "":
set_slots += ", "
keys += key
vals.append( data[key] )
ins_slots += "?"
set_slots += key + " = ?"
keys += ", " + pkname
vals.append( pkval )
ins_slots += ", ?"
condition = pkname + " = ?"
sql_ins = ( "INSERT OR IGNORE INTO " + tablename +
" ( " + keys + " ) " +
" VALUES ( " + ins_slots + " )" )
cur.execute( sql_ins , vals )
sql_upd = ( "UPDATE " + tablename +
" SET " + set_slots + " WHERE " + condition )
cur.execute( sql_upd , vals )

View File

@ -0,0 +1,758 @@
"""
A python class to manage communication with Comic Vine's REST API
"""
"""
Copyright 2012-2014 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 json
from pprint import pprint
import urllib2, urllib
import math
import re
import time
import datetime
import ctversion
import sys
from bs4 import BeautifulSoup
try:
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject():
def __init__(self,*args):
pass
class pyqtSignal():
def __init__(self,*args):
pass
def emit(a,b,c):
pass
import utils
from settings import ComicTaggerSettings
from comicvinecacher import ComicVineCacher
from genericmetadata import GenericMetadata
from issuestring import IssueString
class CVTypeID:
Volume = "4050"
Issue = "4000"
class ComicVineTalkerException(Exception):
Unknown = -1
Network = -2
InvalidKey = 100
RateLimit = 107
def __init__(self, code=-1, desc=""):
self.desc = desc
self.code = code
def __str__(self):
if (self.code == ComicVineTalkerException.Unknown or
self.code == ComicVineTalkerException.Network):
return self.desc
else:
return "CV error #{0}: [{1}]. \n".format( self.code, self.desc )
class ComicVineTalker(QObject):
logo_url = "http://static.comicvine.com/bundles/comicvinesite/images/logo.png"
api_key = ""
@staticmethod
def getRateLimitMessage():
if ComicVineTalker.api_key == "":
return "Comic Vine rate limit exceeded. You should configue your own Comic Vine API key."
else:
return "Comic Vine rate limit exceeded. Please wait a bit."
def __init__(self):
QObject.__init__(self)
self.api_base_url = "http://api.comicvine.com/"
self.wait_for_rate_limit = False
# key that is registered to comictagger
default_api_key = '27431e6787042105bd3e47e169a624521f89f3a4'
if ComicVineTalker.api_key == "":
self.api_key = default_api_key
else:
self.api_key = ComicVineTalker.api_key
self.log_func = None
def setLogFunc( self , log_func ):
self.log_func = log_func
def writeLog( self , text ):
if self.log_func is None:
#sys.stdout.write(text.encode( errors='replace') )
#sys.stdout.flush()
print >> sys.stderr, text
else:
self.log_func( text )
def parseDateStr( self, date_str):
day = None
month = None
year = None
if date_str is not None:
parts = date_str.split('-')
year = parts[0]
if len(parts) > 1:
month = parts[1]
if len(parts) > 2:
day = parts[2]
return day, month, year
def testKey( self, key ):
test_url = self.api_base_url + "/issue/1/?api_key=" + key + "&format=json&field_list=name"
resp = urllib2.urlopen( test_url )
content = resp.read()
cv_response = json.loads( content )
# Bogus request, but if the key is wrong, you get error 100: "Invalid API Key"
return cv_response[ 'status_code' ] != 100
"""
Get the contect from the CV server. If we're in "wait mode" and status code is a rate limit error
sleep for a bit and retry.
"""
def getCVContent(self, url):
total_time_waited = 0
limit_wait_time = 1
counter = 0
wait_times = [1,2,3,4]
while True:
time.sleep(2) #imposed by new CV rate limiting - 1 api request / second (maximum) - safetyset to 2s intervals
#self.writeLog( "Self imposed rate-limiting of 1 api request / second\n" )
content = self.getUrlContent(url)
cv_response = json.loads(content)
if self.wait_for_rate_limit and cv_response[ 'status_code' ] == ComicVineTalkerException.RateLimit:
self.writeLog( "Rate limit encountered. Waiting for {0} minutes\n".format(limit_wait_time))
time.sleep(limit_wait_time * 60)
total_time_waited += limit_wait_time
limit_wait_time = wait_times[counter]
if counter < 3:
counter += 1
# don't wait much more than 20 minutes
if total_time_waited < 20:
continue
if cv_response[ 'status_code' ] != 1:
self.writeLog( "Comic Vine query failed with error #{0}: [{1}]. \n".format( cv_response[ 'status_code' ], cv_response[ 'error' ] ))
raise ComicVineTalkerException(cv_response[ 'status_code' ], cv_response[ 'error' ] )
else:
# it's all good
break
return cv_response
def getUrlContent( self, url ):
# connect to server:
# if there is a 500 error, try a few more times before giving up
# any other error, just bail
#print "ATB---", url
for tries in range(3):
try:
resp = urllib2.urlopen( url )
return resp.read()
except urllib2.HTTPError as e:
if e.getcode() == 500:
self.writeLog( "Try #{0}: ".format(tries+1) )
time.sleep(1)
self.writeLog( str(e) + "\n" )
if e.getcode() != 500:
break
except Exception as e:
self.writeLog( str(e) + "\n" )
raise ComicVineTalkerException(ComicVineTalkerException.Network, "Network Error!")
raise ComicVineTalkerException(ComicVineTalkerException.Unknown, "Error on Comic Vine server")
def searchForSeries( self, series_name , callback=None, refresh_cache=False ):
# remove cruft from the search string
series_name = utils.removearticles( series_name ).lower().strip()
# before we search online, look in our cache, since we might have
# done this same search recently
cvc = ComicVineCacher( )
if not refresh_cache:
cached_search_results = cvc.get_search_results( series_name )
if len (cached_search_results) > 0:
return cached_search_results
original_series_name = series_name
# We need to make the series name into an "AND"ed query list
query_word_list = series_name.split()
and_list = ['AND'] * (len(query_word_list)-1)
and_list.append('')
# zipper up the two lists
query_list = zip(query_word_list, and_list)
# flatten the list
query_list = [ item for sublist in query_list for item in sublist]
# convert back to a string
query_string = " ".join( query_list ).strip()
#print "Query string = ", query_string
query_string = urllib.quote_plus(query_string.encode("utf-8"))
search_url = self.api_base_url + "/search/?api_key=" + self.api_key + "&format=json&resources=volume&query=" + query_string + "&field_list=name,id,start_year,publisher,image,description,count_of_issues"
cv_response = self.getCVContent(search_url + "&page=1")
search_results = list()
# see http://api.comicvine.com/documentation/#handling_responses
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
if callback is None:
self.writeLog( "Found {0} of {1} results\n".format( cv_response['number_of_page_results'], cv_response['number_of_total_results']))
search_results.extend( cv_response['results'])
page = 1
if callback is not None:
callback( current_result_count, total_result_count )
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
if callback is None:
self.writeLog("getting another page of results {0} of {1}...\n".format( current_result_count, total_result_count))
page += 1
cv_response = self.getCVContent(search_url + "&page="+str(page))
search_results.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
if callback is not None:
callback( current_result_count, total_result_count )
#for record in search_results:
# #print( u"{0}: {1} ({2})".format(record['id'], record['name'] , record['start_year'] ) )
# #print record
# #record['count_of_issues'] = record['count_of_isssues']
#print u"{0}: {1} ({2})".format(search_results['results'][0]['id'], search_results['results'][0]['name'] , search_results['results'][0]['start_year'] )
# cache these search results
cvc.add_search_results( original_series_name, search_results )
return search_results
def fetchVolumeData( self, series_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
cached_volume_result = cvc.get_volume_info( series_id )
if cached_volume_result is not None:
return cached_volume_result
volume_url = self.api_base_url + "/volume/" + CVTypeID.Volume + "-" + str(series_id) + "/?api_key=" + self.api_key + "&field_list=name,id,start_year,publisher,count_of_issues&format=json"
cv_response = self.getCVContent(volume_url)
volume_results = cv_response['results']
cvc.add_volume_info( volume_results )
return volume_results
def fetchIssuesByVolume( self, series_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
cached_volume_issues_result = cvc.get_volume_issues_info( series_id )
if cached_volume_issues_result is not None:
return cached_volume_issues_result
#---------------------------------
issues_url = self.api_base_url + "/issues/" + "?api_key=" + self.api_key + "&filter=volume:" + str(series_id) + "&field_list=id,volume,issue_number,name,image,cover_date,site_detail_url,description&format=json"
cv_response = self.getCVContent(issues_url)
#------------------------------------
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
#print "ATB total_result_count", total_result_count
#print "ATB Found {0} of {1} results".format( cv_response['number_of_page_results'], cv_response['number_of_total_results'])
volume_issues_result = cv_response['results']
page = 1
offset = 0
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
#print "ATB getting another page of issue results {0} of {1}...".format( current_result_count, total_result_count)
page += 1
offset += cv_response['number_of_page_results']
#print issues_url+ "&offset="+str(offset)
cv_response = self.getCVContent(issues_url + "&offset="+str(offset))
volume_issues_result.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
self.repairUrls( volume_issues_result )
cvc.add_volume_issues_info( series_id, volume_issues_result )
return volume_issues_result
def fetchIssuesByVolumeIssueNumAndYear( self, volume_id_list, issue_number, year ):
volume_filter = "volume:"
for vid in volume_id_list:
volume_filter += str(vid) + "|"
year_filter = ""
if year is not None and str(year).isdigit():
year_filter = ",cover_date:{0}-1-1|{1}-1-1".format(year, int(year)+1)
issue_number = urllib.quote_plus(unicode(issue_number).encode("utf-8"))
filter = "&filter=" + volume_filter + year_filter + ",issue_number:" + issue_number
issues_url = self.api_base_url + "/issues/" + "?api_key=" + self.api_key + filter + "&field_list=id,volume,issue_number,name,image,cover_date,site_detail_url,description&format=json"
cv_response = self.getCVContent(issues_url)
#------------------------------------
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
#print "ATB total_result_count", total_result_count
#print "ATB Found {0} of {1} results\n".format( cv_response['number_of_page_results'], cv_response['number_of_total_results'])
filtered_issues_result = cv_response['results']
page = 1
offset = 0
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
#print "ATB getting another page of issue results {0} of {1}...\n".format( current_result_count, total_result_count)
page += 1
offset += cv_response['number_of_page_results']
#print issues_url+ "&offset="+str(offset)
cv_response = self.getCVContent(issues_url + "&offset="+str(offset))
filtered_issues_result.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
self.repairUrls( filtered_issues_result )
return filtered_issues_result
def fetchIssueData( self, series_id, issue_number, settings ):
volume_results = self.fetchVolumeData( series_id )
issues_list_results = self.fetchIssuesByVolume( series_id )
found = False
for record in issues_list_results:
if IssueString(issue_number).asString() is None:
issue_number = 1
if IssueString(record['issue_number']).asString().lower() == IssueString(issue_number).asString().lower():
found = True
break
if (found):
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(record['id']) + "/?api_key=" + self.api_key + "&format=json"
cv_response = self.getCVContent(issue_url)
issue_results = cv_response['results']
else:
return None
# now, map the comicvine data to generic metadata
return self.mapCVDataToMetadata( volume_results, issue_results, settings )
def fetchIssueDataByIssueID( self, issue_id, settings ):
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json"
cv_response = self.getCVContent(issue_url)
issue_results = cv_response['results']
volume_results = self.fetchVolumeData( issue_results['volume']['id'] )
# now, map the comicvine data to generic metadata
md = self.mapCVDataToMetadata( volume_results, issue_results, settings )
md.isEmpty = False
return md
def mapCVDataToMetadata(self, volume_results, issue_results, settings ):
# now, map the comicvine data to generic metadata
metadata = GenericMetadata()
metadata.series = issue_results['volume']['name']
num_s = IssueString(issue_results['issue_number']).asString()
metadata.issue = num_s
metadata.title = issue_results['name']
metadata.publisher = volume_results['publisher']['name']
metadata.day, metadata.month, metadata.year = self.parseDateStr( issue_results['cover_date'] )
#metadata.issueCount = volume_results['count_of_issues']
metadata.comments = self.cleanup_html(issue_results['description'], settings.remove_html_tables)
if settings.use_series_start_as_volume:
metadata.volume = volume_results['start_year']
metadata.notes = "Tagged with ComicTagger {0} using info from Comic Vine on {1}. [Issue ID {2}]".format(
ctversion.version,
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
issue_results['id'])
#metadata.notes += issue_results['site_detail_url']
metadata.webLink = issue_results['site_detail_url']
person_credits = issue_results['person_credits']
for person in person_credits:
if person.has_key('role'):
roles = person['role'].split(',')
for role in roles:
# can we determine 'primary' from CV??
metadata.addCredit( person['name'], role.title().strip(), False )
character_credits = issue_results['character_credits']
character_list = list()
for character in character_credits:
character_list.append( character['name'] )
metadata.characters = utils.listToString( character_list )
team_credits = issue_results['team_credits']
team_list = list()
for team in team_credits:
team_list.append( team['name'] )
metadata.teams = utils.listToString( team_list )
location_credits = issue_results['location_credits']
location_list = list()
for location in location_credits:
location_list.append( location['name'] )
metadata.locations = utils.listToString( location_list )
story_arc_credits = issue_results['story_arc_credits']
arc_list = []
for arc in story_arc_credits:
arc_list.append(arc['name'])
if len(arc_list) > 0:
metadata.storyArc = utils.listToString(arc_list)
return metadata
def cleanup_html( self, string, remove_html_tables):
"""
converter = html2text.HTML2Text()
#converter.emphasis_mark = '*'
#converter.ignore_links = True
converter.body_width = 0
print html2text.html2text(string)
return string
#return converter.handle(string)
"""
if string is None:
return ""
# find any tables
soup = BeautifulSoup(string)
tables = soup.findAll('table')
# remove all newlines first
string = string.replace("\n", "")
#put in our own
string = string.replace("<br>", "\n")
string = string.replace("</p>", "\n\n")
string = string.replace("<h4>", "*")
string = string.replace("</h4>", "*\n")
#remove the tables
p = re.compile(r'<table[^<]*?>.*?<\/table>')
if remove_html_tables:
string = p.sub('',string)
string = string.replace("*List of covers and their creators:*","")
else:
string = p.sub('{}',string)
# now strip all other tags
p = re.compile(r'<[^<]*?>')
newstring = p.sub('',string)
newstring = newstring.replace('&nbsp;',' ')
newstring = newstring.replace('&amp;','&')
newstring = newstring.strip()
if not remove_html_tables:
# now rebuild the tables into text from BSoup
try:
table_strings = []
for table in tables:
rows = []
hdrs = []
col_widths = []
for hdr in table.findAll('th'):
item = hdr.string.strip()
hdrs.append(item)
col_widths.append(len(item))
rows.append(hdrs)
for row in table.findAll('tr'):
cols = []
col = row.findAll('td')
i = 0
for c in col:
item = c.string.strip()
cols.append(item)
if len(item) > col_widths[i]:
col_widths[i] = len(item)
i += 1
if len(cols) != 0:
rows.append(cols)
# now we have the data, make it into text
fmtstr =""
for w in col_widths:
fmtstr += " {{:{}}}|".format(w+1)
width = sum(col_widths) + len(col_widths)*2
table_text = ""
counter = 0
for row in rows:
table_text += fmtstr.format(*row) + "\n"
if counter == 0 and len(hdrs)!= 0:
table_text += "-" * width + "\n"
counter += 1
table_strings.append(table_text)
newstring = newstring.format(*table_strings)
except:
# we caught an error rebuilding the table.
# just bail and remove the formatting
print "table parse error"
newstring.replace("{}", "")
return newstring
def fetchIssueDate( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
day, month, year = self.parseDateStr( details['cover_date'] )
return month, year
def fetchIssueCoverURLs( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
return details['image_url'], details['thumb_image_url']
def fetchIssuePageURL( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
return details['site_detail_url']
def fetchIssueSelectDetails( self, issue_id ):
#cached_image_url,cached_thumb_url,cached_month,cached_year = self.fetchCachedIssueSelectDetails( issue_id )
cached_details = self.fetchCachedIssueSelectDetails( issue_id )
if cached_details['image_url'] is not None:
return cached_details
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json&field_list=image,cover_date,site_detail_url"
details = dict()
details['image_url'] = None
details['thumb_image_url'] = None
details['cover_date'] = None
details['site_detail_url'] = None
cv_response = self.getCVContent(issue_url)
details['image_url'] = cv_response['results']['image']['super_url']
details['thumb_image_url'] = cv_response['results']['image']['thumb_url']
details['cover_date'] = cv_response['results']['cover_date']
details['site_detail_url'] = cv_response['results']['site_detail_url']
if details['image_url'] is not None:
self.cacheIssueSelectDetails( issue_id,
details['image_url'],
details['thumb_image_url'],
details['cover_date'],
details['site_detail_url'] )
#print details['site_detail_url']
return details
def fetchCachedIssueSelectDetails( self, issue_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
return cvc.get_issue_select_details( issue_id )
def cacheIssueSelectDetails( self, issue_id, image_url, thumb_url, cover_date, page_url ):
cvc = ComicVineCacher( )
cvc.add_issue_select_details( issue_id, image_url, thumb_url, cover_date, page_url )
def fetchAlternateCoverURLs(self, issue_id, issue_page_url):
url_list = self.fetchCachedAlternateCoverURLs( issue_id )
if url_list is not None:
return url_list
# scrape the CV issue page URL to get the alternate cover URLs
resp = urllib2.urlopen( issue_page_url )
content = resp.read()
alt_cover_url_list = self.parseOutAltCoverUrls( content)
# cache this alt cover URL list
self.cacheAlternateCoverURLs( issue_id, alt_cover_url_list )
return alt_cover_url_list
def parseOutAltCoverUrls( self, page_html ):
soup = BeautifulSoup( page_html )
alt_cover_url_list = []
# Using knowledge of the layout of the ComicVine issue page here:
# look for the divs that are in the classes 'content-pod' and 'alt-cover'
div_list = soup.find_all( 'div')
covers_found = 0
for d in div_list:
if d.has_key('class'):
c = d['class']
if 'imgboxart' in c and 'issue-cover' in c:
covers_found += 1
if covers_found != 1:
alt_cover_url_list.append( d.img['src'] )
return alt_cover_url_list
def fetchCachedAlternateCoverURLs( self, issue_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
url_list = cvc.get_alt_covers( issue_id )
if url_list is not None:
return url_list
else:
return None
def cacheAlternateCoverURLs( self, issue_id, url_list ):
cvc = ComicVineCacher( )
cvc.add_alt_covers( issue_id, url_list )
#---------------------------------------------------------------------------
urlFetchComplete = pyqtSignal( str , str, int)
def asyncFetchIssueCoverURLs( self, issue_id ):
self.issue_id = issue_id
details = self.fetchCachedIssueSelectDetails( issue_id )
if details['image_url'] is not None:
self.urlFetchComplete.emit( details['image_url'],details['thumb_image_url'], self.issue_id )
return
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json&field_list=image,cover_date,site_detail_url"
self.nam = QNetworkAccessManager()
self.nam.finished.connect( self.asyncFetchIssueCoverURLComplete )
self.nam.get(QNetworkRequest(QUrl(issue_url)))
def asyncFetchIssueCoverURLComplete( self, reply ):
# read in the response
data = reply.readAll()
try:
cv_response = json.loads(str(data))
except:
print >> sys.stderr, "Comic Vine query failed to get JSON data"
print >> sys.stderr, str(data)
return
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return
image_url = cv_response['results']['image']['super_url']
thumb_url = cv_response['results']['image']['thumb_url']
cover_date = cv_response['results']['cover_date']
page_url = cv_response['results']['site_detail_url']
self.cacheIssueSelectDetails( self.issue_id, image_url, thumb_url, cover_date, page_url )
self.urlFetchComplete.emit( image_url, thumb_url, self.issue_id )
altUrlListFetchComplete = pyqtSignal( list, int)
def asyncFetchAlternateCoverURLs( self, issue_id, issue_page_url ):
# This async version requires the issue page url to be provided!
self.issue_id = issue_id
url_list = self.fetchCachedAlternateCoverURLs( issue_id )
if url_list is not None:
self.altUrlListFetchComplete.emit( url_list, int(self.issue_id) )
return
self.nam = QNetworkAccessManager()
self.nam.finished.connect( self.asyncFetchAlternateCoverURLsComplete )
self.nam.get(QNetworkRequest(QUrl(str(issue_page_url))))
def asyncFetchAlternateCoverURLsComplete( self, reply ):
# read in the response
html = str(reply.readAll())
alt_cover_url_list = self.parseOutAltCoverUrls( html )
# cache this alt cover URL list
self.cacheAlternateCoverURLs( self.issue_id, alt_cover_url_list )
self.altUrlListFetchComplete.emit( alt_cover_url_list, int(self.issue_id) )
def repairUrls(self, issue_list):
#make sure there are URLs for the image fields
for issue in issue_list:
if issue['image'] is None:
issue['image'] = dict()
issue['image']['super_url'] = ComicVineTalker.logo_url
issue['image']['thumb_url'] = ComicVineTalker.logo_url

View File

@ -0,0 +1,311 @@
"""
A PyQt4 widget display cover images from either local archive, or from ComicVine
(TODO: This should be re-factored using subclasses!)
"""
"""
Copyright 2012-2014 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 os
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from settings import ComicTaggerSettings
from genericmetadata import GenericMetadata, PageType
from comicarchive import MetaDataStyle
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from imagefetcher import ImageFetcher
from pageloader import PageLoader
from imagepopup import ImagePopup
import utils
# helper func to allow a label to be clickable
def clickable(widget):
class Filter(QObject):
dblclicked = pyqtSignal()
def eventFilter(self, obj, event):
if obj == widget:
if event.type() == QEvent.MouseButtonDblClick:
self.dblclicked.emit()
return True
return False
filter = Filter(widget)
widget.installEventFilter(filter)
return filter.dblclicked
class CoverImageWidget(QWidget):
ArchiveMode = 0
AltCoverMode = 1
URLMode = 1
DataMode = 3
def __init__(self, parent, mode, expand_on_click = True ):
super(CoverImageWidget, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('coverimagewidget.ui' ), self)
utils.reduceWidgetFontSize( self.label )
self.mode = mode
self.comicVine = ComicVineTalker()
self.page_loader = None
self.showControls = True
self.btnLeft.setIcon(QIcon(ComicTaggerSettings.getGraphic('left.png')))
self.btnRight.setIcon(QIcon(ComicTaggerSettings.getGraphic('right.png')))
self.btnLeft.clicked.connect( self.decrementImage )
self.btnRight.clicked.connect( self.incrementImage )
self.resetWidget()
if expand_on_click:
clickable(self.lblImage).connect(self.showPopup)
else:
self.lblImage.setToolTip( "" )
self.updateContent()
def resetWidget(self):
self.comic_archive = None
self.issue_id = None
self.comicVine = None
self.cover_fetcher = None
self.url_list = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = None
def clear( self ):
self.resetWidget()
self.updateContent()
def incrementImage( self ):
self.imageIndex += 1
if self.imageIndex == self.imageCount:
self.imageIndex = 0
self.updateContent()
def decrementImage( self ):
self.imageIndex -= 1
if self.imageIndex == -1:
self.imageIndex = self.imageCount -1
self.updateContent()
def setArchive( self, ca, page=0 ):
if self.mode == CoverImageWidget.ArchiveMode:
self.resetWidget()
self.comic_archive = ca
self.imageIndex = page
self.imageCount = ca.getNumberOfPages()
self.updateContent()
def setURL( self, url ):
if self.mode == CoverImageWidget.URLMode:
self.resetWidget()
self.updateContent()
self.url_list = [ url ]
self.imageIndex = 0
self.imageCount = 1
self.updateContent()
def setIssueID( self, issue_id ):
if self.mode == CoverImageWidget.AltCoverMode:
self.resetWidget()
self.updateContent()
self.issue_id = issue_id
self.comicVine = ComicVineTalker()
self.comicVine.urlFetchComplete.connect( self.primaryUrlFetchComplete )
self.comicVine.asyncFetchIssueCoverURLs( int(self.issue_id) )
def setImageData( self, image_data ):
if self.mode == CoverImageWidget.DataMode:
self.resetWidget()
if image_data is None:
self.imageIndex = -1
else:
self.imageIndex = 0
self.imageData = image_data
self.updateContent()
def primaryUrlFetchComplete( self, primary_url, thumb_url, issue_id ):
self.url_list.append(str(primary_url))
self.imageIndex = 0
self.imageCount = len(self.url_list)
self.updateContent()
#defer the alt cover search
QTimer.singleShot(1, self.startAltCoverSearch)
def startAltCoverSearch( self ):
# now we need to get the list of alt cover URLs
self.label.setText("Searching for alt. covers...")
# page URL should already be cached, so no need to defer
self.comicVine = ComicVineTalker()
issue_page_url = self.comicVine.fetchIssuePageURL( self.issue_id )
self.comicVine.altUrlListFetchComplete.connect( self.altCoverUrlListFetchComplete )
self.comicVine.asyncFetchAlternateCoverURLs( int(self.issue_id), issue_page_url)
def altCoverUrlListFetchComplete( self, url_list, issue_id ):
if len(url_list) > 0:
self.url_list.extend(url_list)
self.imageCount = len(self.url_list)
self.updateControls()
def setPage( self, pagenum ):
if self.mode == CoverImageWidget.ArchiveMode:
self.imageIndex = pagenum
self.updateContent()
def updateContent( self ):
self.updateImage()
self.updateControls()
def updateImage( self ):
if self.imageIndex == -1:
self.loadDefault()
elif self.mode in [ CoverImageWidget.AltCoverMode, CoverImageWidget.URLMode ]:
self.loadURL()
elif self.mode == CoverImageWidget.DataMode:
self.coverRemoteFetchComplete( self.imageData, 0 )
else:
self.loadPage()
def updateControls( self ):
if not self.showControls or self.mode == CoverImageWidget.DataMode:
self.btnLeft.hide()
self.btnRight.hide()
self.label.hide()
return
if self.imageIndex == -1 or self.imageCount == 1:
self.btnLeft.setEnabled(False)
self.btnRight.setEnabled(False)
self.btnLeft.hide()
self.btnRight.hide()
else:
self.btnLeft.setEnabled(True)
self.btnRight.setEnabled(True)
self.btnLeft.show()
self.btnRight.show()
if self.imageIndex == -1 or self.imageCount == 1:
self.label.setText("")
elif self.mode == CoverImageWidget.AltCoverMode:
self.label.setText("Cover {0} ( of {1} )".format(self.imageIndex+1, self.imageCount))
else:
self.label.setText("Page {0} ( of {1} )".format(self.imageIndex+1, self.imageCount))
def loadURL( self ):
self.loadDefault()
self.cover_fetcher = ImageFetcher( )
self.cover_fetcher.fetchComplete.connect(self.coverRemoteFetchComplete)
self.cover_fetcher.fetch( self.url_list[self.imageIndex] )
#print "ATB cover fetch started...."
# called when the image is done loading from internet
def coverRemoteFetchComplete( self, image_data, issue_id ):
img = utils.getQImageFromData( image_data)
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap( 0, 0)
#print "ATB cover fetch complete!"
def loadPage( self ):
if self.comic_archive is not None:
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = PageLoader( self.comic_archive, self.imageIndex )
self.page_loader.loadComplete.connect( self.pageLoadComplete )
self.page_loader.start()
def pageLoadComplete( self, img ):
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap( 0, 0)
self.page_loader = None
def loadDefault( self ):
self.current_pixmap = QPixmap(ComicTaggerSettings.getGraphic('nocover.png'))
#print "loadDefault called"
self.setDisplayPixmap( 0, 0)
def resizeEvent( self, resize_event ):
if self.current_pixmap is not None:
delta_w = resize_event.size().width() - resize_event.oldSize().width()
delta_h = resize_event.size().height() - resize_event.oldSize().height()
#print "ATB resizeEvent deltas", resize_event.size().width(), resize_event.size().height()
self.setDisplayPixmap( delta_w , delta_h )
def setDisplayPixmap( self, delta_w , delta_h ):
# the deltas let us know what the new width and height of the label will be
"""
new_h = self.frame.height() + delta_h
new_w = self.frame.width() + delta_w
print "ATB setDisplayPixmap deltas", delta_w , delta_h
print "ATB self.frame", self.frame.width(), self.frame.height()
print "ATB self.", self.width(), self.height()
frame_w = new_w
frame_h = new_h
"""
new_h = self.frame.height()
new_w = self.frame.width()
frame_w = self.frame.width()
frame_h = self.frame.height()
new_h -= 4
new_w -= 4
if new_h < 0:
new_h = 0;
if new_w < 0:
new_w = 0;
#print "ATB setDisplayPixmap deltas", delta_w , delta_h
#print "ATB self.frame", frame_w, frame_h
#print "ATB new size", new_w, new_h
# scale the pixmap to fit in the frame
scaled_pixmap = self.current_pixmap.scaled(new_w, new_h, Qt.KeepAspectRatio)
self.lblImage.setPixmap( scaled_pixmap )
# move and resize the label to be centered in the fame
img_w = scaled_pixmap.width()
img_h = scaled_pixmap.height()
self.lblImage.resize( img_w, img_h )
self.lblImage.move( (frame_w - img_w)/2, (frame_h - img_h)/2 )
def showPopup( self ):
self.popup = ImagePopup(self, self.current_pixmap)

View File

@ -0,0 +1,99 @@
"""
A PyQT4 dialog to edit credits
"""
"""
Copyright 2012-2014 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.
"""
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
import os
class CreditEditorWindow(QtGui.QDialog):
ModeEdit = 0
ModeNew = 1
def __init__(self, parent, mode, role, name, primary ):
super(CreditEditorWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('crediteditorwindow.ui' ), self)
self.mode = mode
if self.mode == self.ModeEdit:
self.setWindowTitle("Edit Credit")
else:
self.setWindowTitle("New Credit")
# Add the entries to the role combobox
self.cbRole.addItem( "" )
self.cbRole.addItem( "Writer" )
self.cbRole.addItem( "Artist" )
self.cbRole.addItem( "Penciller" )
self.cbRole.addItem( "Inker" )
self.cbRole.addItem( "Colorist" )
self.cbRole.addItem( "Letterer" )
self.cbRole.addItem( "Cover Artist" )
self.cbRole.addItem( "Editor" )
self.cbRole.addItem( "Other" )
self.cbRole.addItem( "Plotter" )
self.cbRole.addItem( "Scripter" )
self.leName.setText( name )
if role is not None and role != "":
i = self.cbRole.findText( role )
if i == -1:
self.cbRole.setEditText( role )
else:
self.cbRole.setCurrentIndex( i )
if primary:
self.cbPrimary.setCheckState( QtCore.Qt.Checked )
self.cbRole.currentIndexChanged.connect(self.roleChanged)
self.cbRole.editTextChanged.connect(self.roleChanged)
self.updatePrimaryButton()
def updatePrimaryButton( self ):
enabled =self.currentRoleCanBePrimary()
self.cbPrimary.setEnabled( enabled )
def currentRoleCanBePrimary( self ):
role = self.cbRole.currentText()
if str(role).lower() == "writer" or str(role).lower() == "artist":
return True
else:
return False
def roleChanged( self, s ):
self.updatePrimaryButton()
def getCredits( self ):
primary = self.currentRoleCanBePrimary() and self.cbPrimary.isChecked()
return self.cbRole.currentText(), self.leName.text(), primary
def accept( self ):
if self.cbRole.currentText() == "" or self.leName.text() == "":
QtGui.QMessageBox.warning(self, self.tr("Whoops"), self.tr("You need to enter both role and name for a credit."))
else:
QtGui.QDialog.accept(self)

View File

@ -0,0 +1,3 @@
# This file should contan only these comments, and the line below.
# Used by packaging makefiles and app
version="1.1.15-beta"

View File

@ -0,0 +1,65 @@
"""
A PyQT4 dialog to confirm and set options for export to zip
"""
"""
Copyright 2012-2014 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.
"""
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from settingswindow import SettingsWindow
from filerenamer import FileRenamer
import os
import utils
class ExportConflictOpts:
dontCreate = 1
overwrite = 2
createUnique = 3
class ExportWindow(QtGui.QDialog):
def __init__( self, parent, settings, msg ):
super(ExportWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('exportwindow.ui' ), self)
self.label.setText( msg )
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint )
self.settings = settings
self.cbxDeleteOriginal.setCheckState( QtCore.Qt.Unchecked )
self.cbxAddToList.setCheckState( QtCore.Qt.Checked )
self.radioDontCreate.setChecked( True )
self.deleteOriginal = False
self.addToList = True
self.fileConflictBehavior = ExportConflictOpts.dontCreate
def accept( self ):
QtGui.QDialog.accept(self)
self.deleteOriginal = self.cbxDeleteOriginal.isChecked()
self.addToList = self.cbxAddToList.isChecked()
if self.radioDontCreate.isChecked():
self.fileConflictBehavior = ExportConflictOpts.dontCreate
elif self.radioCreateNew.isChecked():
self.fileConflictBehavior = ExportConflictOpts.createUnique
#else:
# self.fileConflictBehavior = ExportConflictOpts.overwrite

View File

@ -0,0 +1,277 @@
"""
Functions for parsing comic info from filename
This should probably be re-written, but, well, it mostly works!
"""
"""
Copyright 2012-2014 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.
"""
# Some portions of this code were modified from pyComicMetaThis project
# http://code.google.com/p/pycomicmetathis/
import re
import os
from urllib import unquote
class FileNameParser:
def repl(self, m):
return ' ' * len(m.group())
def fixSpaces( self, string, remove_dashes=True ):
if remove_dashes:
placeholders = ['[-_]',' +']
else:
placeholders = ['[_]',' +']
for ph in placeholders:
string = re.sub(ph, self.repl, string )
return string #.strip()
def getIssueCount( self,filename, issue_end ):
count = ""
filename = filename[issue_end:]
# replace any name seperators with spaces
tmpstr = self.fixSpaces(filename)
found = False
match = re.search('(?<=\sof\s)\d+(?=\s)', tmpstr, re.IGNORECASE)
if match:
count = match.group()
found = True
if not found:
match = re.search('(?<=\(of\s)\d+(?=\))', tmpstr, re.IGNORECASE)
if match:
count = match.group()
found = True
count = count.lstrip("0")
return count
def getIssueNumber( self, filename ):
# Returns a tuple of issue number string, and start and end indexs in the filename
# (The indexes will be used to split the string up for further parsing)
found = False
issue = ''
start = 0
end = 0
# first, look for multiple "--", this means it's formatted differently from most:
if "--" in filename:
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
filename = re.sub("--.*", self.repl, filename)
elif "__" in filename:
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
filename = re.sub("__.*", self.repl, filename)
filename = filename.replace("+", " ")
# replace parenthetical phrases with spaces
filename = re.sub( "\(.*?\)", self.repl, filename)
filename = re.sub( "\[.*?\]", self.repl, filename)
# replace any name seperators with spaces
filename = self.fixSpaces(filename)
# remove any "of NN" phrase with spaces (problem: this could break on some titles)
filename = re.sub( "of [\d]+", self.repl, filename)
#print u"[{0}]".format(filename)
# we should now have a cleaned up filename version with all the words in
# the same positions as original filename
# make a list of each word and its position
word_list = list()
for m in re.finditer("\S+", filename):
word_list.append( (m.group(0), m.start(), m.end()) )
# remove the first word, since it can't be the issue number
if len(word_list) > 1:
word_list = word_list[1:]
else:
#only one word?? just bail.
return issue, start, end
# Now try to search for the likely issue number word in the list
# first look for a word with "#" followed by digits with optional sufix
# this is almost certainly the issue number
for w in reversed(word_list):
if re.match("#[-]?(([0-9]*\.[0-9]+|[0-9]+)(\w*))", w[0]):
found = True
break
# same as above but w/o a '#', and only look at the last word in the list
if not found:
w = word_list[-1]
if re.match("[-]?(([0-9]*\.[0-9]+|[0-9]+)(\w*))", w[0]):
found = True
# now try to look for a # followed by any characters
if not found:
for w in reversed(word_list):
if re.match("#\S+", w[0]):
found = True
break
if found:
issue = w[0]
start = w[1]
end = w[2]
if issue[0] == '#':
issue = issue[1:]
return issue, start, end
def getSeriesName(self, filename, issue_start ):
# use the issue number string index to split the filename string
if issue_start != 0:
filename = filename[:issue_start]
# in case there is no issue number, remove some obvious stuff
if "--" in filename:
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
filename = re.sub("--.*", self.repl, filename)
elif "__" in filename:
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
filename = re.sub("__.*", self.repl, filename)
filename = filename.replace("+", " ")
tmpstr = self.fixSpaces(filename, remove_dashes=False)
series = tmpstr
volume = ""
#save the last word
try:
last_word = series.split()[-1]
except:
last_word = ""
# remove any parenthetical phrases
series = re.sub( "\(.*?\)", "", series)
# search for volume number
match = re.search('(.+)([vV]|[Vv][oO][Ll]\.?\s?)(\d+)\s*$', series)
if match:
series = match.group(1)
volume = match.group(3)
# if a volume wasn't found, see if the last word is a year in parentheses
# since that's a common way to designate the volume
if volume == "":
#match either (YEAR), (YEAR-), or (YEAR-YEAR2)
match = re.search("(\()(\d{4})(-(\d{4}|)|)(\))", last_word)
if match:
volume = match.group(2)
series = series.strip()
# if we don't have an issue number (issue_start==0), look
# for hints i.e. "TPB", "one-shot", "OS", "OGN", etc that might
# be removed to help search online
if issue_start == 0:
one_shot_words = [ "tpb", "os", "one-shot", "ogn", "gn" ]
try:
last_word = series.split()[-1]
if last_word.lower() in one_shot_words:
series = series.rsplit(' ', 1)[0]
except:
pass
return series, volume.strip()
def getYear( self,filename, issue_end):
filename = filename[issue_end:]
year = ""
# look for four digit number with "(" ")" or "--" around it
match = re.search('(\(\d\d\d\d\))|(--\d\d\d\d--)', filename)
if match:
year = match.group()
# remove non-numerics
year = re.sub("[^0-9]", "", year)
return year
def getRemainder( self, filename, year, count, issue_end ):
#make a guess at where the the non-interesting stuff begins
remainder = ""
if "--" in filename:
remainder = filename.split("--",1)[1]
elif "__" in filename:
remainder = filename.split("__",1)[1]
elif issue_end != 0:
remainder = filename[issue_end:]
remainder = self.fixSpaces(remainder, remove_dashes=False)
if year != "":
remainder = remainder.replace(year,"",1)
if count != "":
remainder = remainder.replace("of "+count,"",1)
remainder = remainder.replace("()","")
return remainder.strip()
def parseFilename( self, filename ):
# remove the path
filename = os.path.basename(filename)
# remove the extension
filename = os.path.splitext(filename)[0]
#url decode, just in case
filename = unquote(filename)
# sometimes archives get messed up names from too many decodings
# often url encodings will break and leave "_28" and "_29" in place
# of "(" and ")" see if there are a number of these, and replace them
if filename.count("_28") > 1 and filename.count("_29") > 1:
filename = filename.replace("_28", "(")
filename = filename.replace("_29", ")")
self.issue, issue_start, issue_end = self.getIssueNumber(filename)
self.series, self.volume = self.getSeriesName(filename, issue_start)
self.year = self.getYear(filename, issue_end)
self.issue_count = self.getIssueCount(filename, issue_end)
self.remainder = self.getRemainder( filename, self.year, self.issue_count, issue_end )
if self.issue != "":
# strip off leading zeros
self.issue = self.issue.lstrip("0")
if self.issue == "":
self.issue = "0"
if self.issue[0] == ".":
self.issue = "0" + self.issue

View File

@ -0,0 +1,150 @@
"""
Functions for renaming files based on metadata
"""
"""
Copyright 2012-2014 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 os
import re
import datetime
import utils
from issuestring import IssueString
class FileRenamer:
def __init__( self, metadata ):
self.setMetadata( metadata )
self.setTemplate( "%series% v%volume% #%issue% (of %issuecount%) (%year%)" )
self.smart_cleanup = True
self.issue_zero_padding = 3
def setMetadata( self, metadata ):
self.metdata = metadata
def setIssueZeroPadding( self, count ):
self.issue_zero_padding = count
def setSmartCleanup( self, on ):
self.smart_cleanup = on
def setTemplate( self, template ):
self.template = template
def replaceToken( self, text, value, token ):
#helper func
def isToken( word ):
return (word[0] == "%" and word[-1:] == "%")
if value is not None:
return text.replace( token, unicode(value) )
else:
if self.smart_cleanup:
# smart cleanup means we want to remove anything appended to token if it's empty
# (e.g "#%issue%" or "v%volume%" )
# (TODO: This could fail if there is more than one token appended together, I guess)
text_list = text.split()
#special case for issuecount, remove preceding non-token word, as in "...(of %issuecount%)..."
if token == '%issuecount%':
for idx,word in enumerate( text_list ):
if token in word and not isToken(text_list[idx -1]) :
text_list[idx -1] = ""
text_list = [ x for x in text_list if token not in x ]
return " ".join( text_list )
else:
return text.replace( token, "" )
def determineName( self, filename, ext=None ):
md = self.metdata
new_name = self.template
preferred_encoding = utils.get_actual_preferred_encoding()
#print u"{0}".format(md)
new_name = self.replaceToken( new_name, md.series, '%series%')
new_name = self.replaceToken( new_name, md.volume, '%volume%')
if md.issue is not None:
issue_str = u"{0}".format( IssueString(md.issue).asString(pad=self.issue_zero_padding) )
else:
issue_str = None
new_name = self.replaceToken( new_name, issue_str, '%issue%')
new_name = self.replaceToken( new_name, md.issueCount, '%issuecount%')
new_name = self.replaceToken( new_name, md.year, '%year%')
new_name = self.replaceToken( new_name, md.publisher, '%publisher%')
new_name = self.replaceToken( new_name, md.title, '%title%')
new_name = self.replaceToken( new_name, md.month, '%month%')
month_name = None
if md.month is not None:
if (type(md.month) == str and md.month.isdigit()) or type(md.month) == int:
if int(md.month) in range(1,13):
dt = datetime.datetime( 1970, int(md.month), 1, 0, 0)
month_name = dt.strftime(u"%B".encode(preferred_encoding)).decode(preferred_encoding)
new_name = self.replaceToken( new_name, month_name, '%month_name%')
new_name = self.replaceToken( new_name, md.genre, '%genre%')
new_name = self.replaceToken( new_name, md.language, '%language_code%')
new_name = self.replaceToken( new_name, md.criticalRating , '%criticalrating%')
new_name = self.replaceToken( new_name, md.alternateSeries, '%alternateseries%')
new_name = self.replaceToken( new_name, md.alternateNumber, '%alternatenumber%')
new_name = self.replaceToken( new_name, md.alternateCount, '%alternatecount%')
new_name = self.replaceToken( new_name, md.imprint, '%imprint%')
new_name = self.replaceToken( new_name, md.format, '%format%')
new_name = self.replaceToken( new_name, md.maturityRating, '%maturityrating%')
new_name = self.replaceToken( new_name, md.storyArc, '%storyarc%')
new_name = self.replaceToken( new_name, md.seriesGroup, '%seriesgroup%')
new_name = self.replaceToken( new_name, md.scanInfo, '%scaninfo%')
if self.smart_cleanup:
# remove empty braces,brackets, parentheses
new_name = re.sub("\(\s*[-:]*\s*\)", "", new_name )
new_name = re.sub("\[\s*[-:]*\s*\]", "", new_name )
new_name = re.sub("\{\s*[-:]*\s*\}", "", new_name )
# remove duplicate spaces
new_name = u" ".join(new_name.split())
# remove remove duplicate -, _,
new_name = re.sub("[-_]{2,}\s+", "-- ", new_name )
new_name = re.sub("(\s--)+", " --", new_name )
new_name = re.sub("(\s-)+", " -", new_name )
# remove dash or double dash at end of line
new_name = re.sub("[-]{1,2}\s*$", "", new_name )
# remove duplicate spaces (again!)
new_name = u" ".join(new_name.split())
if ext is None:
ext = os.path.splitext( filename )[1]
new_name += ext
# some tweaks to keep various filesystems happy
new_name = new_name.replace("/", "-")
new_name = new_name.replace(" :", " -")
new_name = new_name.replace(": ", " - ")
new_name = new_name.replace(":", "-")
new_name = new_name.replace("?", "")
return new_name

View File

@ -0,0 +1,436 @@
# coding=utf-8
"""
A PyQt4 widget for managing list of comic archive files
"""
"""
Copyright 2012-2014 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 os
import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from PyQt4.QtCore import pyqtSignal
from settings import ComicTaggerSettings
from comicarchive import ComicArchive
from comicarchive import MetaDataStyle
from genericmetadata import GenericMetadata, PageType
from optionalmsgdialog import OptionalMessageDialog
import utils
import platform
import os
class FileTableWidget( QTableWidget ):
def __init__(self, parent ):
super(FileTableWidget, self).__init__(parent)
self.setColumnCount(5)
self.setHorizontalHeaderLabels (["File", "Folder", "CR", "CBL", ""])
self.horizontalHeader().setStretchLastSection( True )
class FileTableWidgetItem(QTableWidgetItem):
def __lt__(self, other):
return (self.data(Qt.UserRole).toBool() <
other.data(Qt.UserRole).toBool())
class FileInfo( ):
def __init__(self, ca ):
self.ca = ca
class FileSelectionList(QWidget):
selectionChanged = pyqtSignal(QVariant)
listCleared = pyqtSignal()
fileColNum = 0
CRFlagColNum = 1
CBLFlagColNum = 2
typeColNum = 3
readonlyColNum = 4
folderColNum = 5
dataColNum = fileColNum
def __init__(self, parent , settings ):
super(FileSelectionList, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('fileselectionlist.ui' ), self)
self.settings = settings
utils.reduceWidgetFontSize( self.twList )
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
self.currentItem = None
self.setContextMenuPolicy(Qt.ActionsContextMenu)
self.modifiedFlag = False
selectAllAction = QAction("Select All", self)
removeAction = QAction("Remove Selected Items", self)
self.separator = QAction("",self)
self.separator.setSeparator(True)
selectAllAction.setShortcut( 'Ctrl+A' )
removeAction.setShortcut( 'Ctrl+X' )
selectAllAction.triggered.connect(self.selectAll)
removeAction.triggered.connect(self.removeSelection)
self.addAction(selectAllAction)
self.addAction(removeAction)
self.addAction(self.separator)
def getSorting(self):
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
return col, order
def setSorting(self, col, order):
col = self.twList.horizontalHeader().setSortIndicator( col, order)
def addAppAction( self, action ):
self.insertAction( None , action )
def setModifiedFlag( self, modified ):
self.modifiedFlag = modified
def selectAll( self ):
self.twList.setRangeSelected( QTableWidgetSelectionRange ( 0, 0, self.twList.rowCount()-1, 5 ), True )
def deselectAll( self ):
self.twList.setRangeSelected( QTableWidgetSelectionRange ( 0, 0, self.twList.rowCount()-1, 5 ), False )
def removeArchiveList( self, ca_list ):
self.twList.setSortingEnabled(False)
for ca in ca_list:
for row in range(self.twList.rowCount()):
row_ca = self.getArchiveByRow( row )
if row_ca == ca:
self.twList.removeRow(row)
break
self.twList.setSortingEnabled(True)
def getArchiveByRow( self, row):
fi = self.twList.item(row, FileSelectionList.dataColNum).data( Qt.UserRole ).toPyObject()
return fi.ca
def getCurrentArchive( self ):
return self.getArchiveByRow( self.twList.currentRow() )
def removeSelection( self ):
row_list = []
for item in self.twList.selectedItems():
if item.column() == 0:
row_list.append(item.row())
if len(row_list) == 0:
return
if self.twList.currentRow() in row_list:
if not self.modifiedFlagVerification( "Remove Archive",
"If you close this archive, data in the form will be lost. Are you sure?"):
return
row_list.sort()
row_list.reverse()
self.twList.currentItemChanged.disconnect( self.currentItemChangedCB )
self.twList.setSortingEnabled(False)
for i in row_list:
self.twList.removeRow(i)
self.twList.setSortingEnabled(True)
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
if self.twList.rowCount() > 0:
# since on a removal, we select row 0, make sure callback occurs if we're already there
if self.twList.currentRow() == 0:
self.currentItemChangedCB( self.twList.currentItem(), None)
self.twList.selectRow(0)
else:
self.listCleared.emit()
def addPathList( self, pathlist ):
filelist = utils.get_recursive_filelist( pathlist )
# we now have a list of files to add
progdialog = QProgressDialog("", "Cancel", 0, len(filelist), self)
progdialog.setWindowTitle( "Adding Files" )
#progdialog.setWindowModality(Qt.WindowModal)
progdialog.setWindowModality(Qt.ApplicationModal)
progdialog.show()
firstAdded = None
self.twList.setSortingEnabled(False)
for idx,f in enumerate(filelist):
QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx)
progdialog.setLabelText(f)
utils.centerWindowOnParent( progdialog )
QCoreApplication.processEvents()
row = self.addPathItem( f )
if firstAdded is None and row is not None:
firstAdded = row
progdialog.close()
if ( self.settings.show_no_unrar_warning and
self.settings.unrar_exe_path == "" and
self.settings.rar_exe_path == "" and
platform.system() != "Windows"):
for f in filelist:
ext = os.path.splitext(f)[1].lower()
if ext == ".rar" or ext ==".cbr":
checked = OptionalMessageDialog.msg( self, "No unrar tool",
"""
It looks like you've tried to open at least one CBR or RAR file.<br><br>
In order for ComicTagger to read this kind of file, you will have to configure
the location of the unrar tool in the settings. Until then, ComicTagger
will not be able recognize these kind of files.
"""
)
self.settings.show_no_unrar_warning = not checked
break
if firstAdded is not None:
self.twList.selectRow(firstAdded)
else:
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
QMessageBox.information(self, self.tr("File Open"), self.tr("Selected file doesn't seem to be a comic archive."))
else:
QMessageBox.information(self, self.tr("File/Folder Open"), self.tr("No comic archives were found."))
self.twList.setSortingEnabled(True)
# Adjust column size
self.twList.resizeColumnsToContents()
self.twList.setColumnWidth(FileSelectionList.CRFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.CBLFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.readonlyColNum, 35)
self.twList.setColumnWidth(FileSelectionList.typeColNum, 45)
if self.twList.columnWidth(FileSelectionList.fileColNum) > 250:
self.twList.setColumnWidth(FileSelectionList.fileColNum, 250)
if self.twList.columnWidth(FileSelectionList.folderColNum ) > 200:
self.twList.setColumnWidth(FileSelectionList.folderColNum, 200)
def isListDupe( self, path ):
r = 0
while r < self.twList.rowCount():
ca = self.getArchiveByRow( r )
if ca.path == path:
return True
r = r + 1
return False
def getCurrentListRow( self, path ):
r = 0
while r < self.twList.rowCount():
ca = self.getArchiveByRow( r )
if ca.path == path:
return r
r = r + 1
return -1
def addPathItem( self, path):
path = unicode( path )
path = os.path.abspath( path )
#print "processing", path
if self.isListDupe(path):
return self.getCurrentListRow(path)
ca = ComicArchive( path, self.settings.rar_exe_path )
if ca.seemsToBeAComicArchive() :
row = self.twList.rowCount()
self.twList.insertRow( row )
fi = FileInfo( ca )
filename_item = QTableWidgetItem()
folder_item = QTableWidgetItem()
cix_item = FileTableWidgetItem()
cbi_item = FileTableWidgetItem()
readonly_item = FileTableWidgetItem()
type_item = QTableWidgetItem()
filename_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
filename_item.setData( Qt.UserRole , fi )
self.twList.setItem(row, FileSelectionList.fileColNum, filename_item)
folder_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.folderColNum, folder_item)
type_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
cix_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
cix_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CRFlagColNum, cix_item)
cbi_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
cbi_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CBLFlagColNum, cbi_item)
readonly_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
readonly_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.readonlyColNum, readonly_item)
self.updateRow( row )
return row
def updateRow( self, row ):
fi = self.twList.item( row, FileSelectionList.dataColNum ).data( Qt.UserRole ).toPyObject()
filename_item = self.twList.item( row, FileSelectionList.fileColNum )
folder_item = self.twList.item( row, FileSelectionList.folderColNum )
cix_item = self.twList.item( row, FileSelectionList.CRFlagColNum )
cbi_item = self.twList.item( row, FileSelectionList.CBLFlagColNum )
type_item = self.twList.item( row, FileSelectionList.typeColNum )
readonly_item = self.twList.item( row, FileSelectionList.readonlyColNum )
item_text = os.path.split(fi.ca.path)[0]
folder_item.setText( item_text )
folder_item.setData( Qt.ToolTipRole, item_text )
item_text = os.path.split(fi.ca.path)[1]
filename_item.setText( item_text )
filename_item.setData( Qt.ToolTipRole, item_text )
if fi.ca.isZip():
item_text = "ZIP"
elif fi.ca.isRar():
item_text = "RAR"
else:
item_text = ""
type_item.setText( item_text )
type_item.setData( Qt.ToolTipRole, item_text )
if fi.ca.hasCIX():
cix_item.setCheckState(Qt.Checked)
cix_item.setData(Qt.UserRole, True)
else:
cix_item.setData(Qt.UserRole, False)
cix_item.setCheckState(Qt.Unchecked)
if fi.ca.hasCBI():
cbi_item.setCheckState(Qt.Checked)
cbi_item.setData(Qt.UserRole, True)
else:
cbi_item.setData(Qt.UserRole, False)
cbi_item.setCheckState(Qt.Unchecked)
if not fi.ca.isWritable():
readonly_item.setCheckState(Qt.Checked)
readonly_item.setData(Qt.UserRole, True)
else:
readonly_item.setData(Qt.UserRole, False)
readonly_item.setCheckState(Qt.Unchecked)
# Reading these will force them into the ComicArchive's cache
fi.ca.readCIX()
fi.ca.hasCBI()
def getSelectedArchiveList( self ):
ca_list = []
for r in range( self.twList.rowCount() ):
item = self.twList.item(r, FileSelectionList.dataColNum)
if self.twList.isItemSelected(item):
fi = item.data( Qt.UserRole ).toPyObject()
ca_list.append(fi.ca)
return ca_list
def updateCurrentRow( self ):
self.updateRow( self.twList.currentRow() )
def updateSelectedRows( self ):
self.twList.setSortingEnabled(False)
for r in range( self.twList.rowCount() ):
item = self.twList.item(r, FileSelectionList.dataColNum)
if self.twList.isItemSelected(item):
self.updateRow( r )
self.twList.setSortingEnabled(True)
def currentItemChangedCB( self, curr, prev ):
new_idx = curr.row()
old_idx = -1
if prev is not None:
old_idx = prev.row()
#print "old {0} new {1}".format(old_idx, new_idx)
if old_idx == new_idx:
return
# don't allow change if modified
if prev is not None and new_idx != old_idx:
if not self.modifiedFlagVerification( "Change Archive",
"If you change archives now, data in the form will be lost. Are you sure?"):
self.twList.currentItemChanged.disconnect( self.currentItemChangedCB )
self.twList.setCurrentItem( prev )
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
# Need to defer this revert selection, for some reason
QTimer.singleShot(1, self.revertSelection)
return
fi = self.twList.item( new_idx, FileSelectionList.dataColNum ).data( Qt.UserRole ).toPyObject()
self.selectionChanged.emit( QVariant(fi))
def revertSelection( self ):
self.twList.selectRow( self.twList.currentRow() )
def modifiedFlagVerification( self, title, desc):
if self.modifiedFlag:
reply = QMessageBox.question(self,
self.tr(title),
self.tr(desc),
QMessageBox.Yes, QMessageBox.No )
if reply != QMessageBox.Yes:
return False
return True
# Attempt to use a special checkbox widget in the cell.
# Couldn't figure out how to disable it with "enabled" colors
#w = QWidget()
#cb = QCheckBox(w)
#cb.setCheckState(Qt.Checked)
#layout = QHBoxLayout()
#layout.addWidget( cb )
#layout.setAlignment(Qt.AlignHCenter)
#layout.setMargin(2)
#w.setLayout(layout)
#self.twList.setCellWidget( row, 2, w )

View File

@ -0,0 +1,316 @@
"""
A python class for internal metadata storage
The goal of this class is to handle ALL the data that might come from various
tagging schemes and databases, such as ComicVine or GCD. This makes conversion
possible, however lossy it might be
"""
"""
Copyright 2012-2014 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 utils
# These page info classes are exactly the same as the CIX scheme, since it's unique
class PageType:
FrontCover = "FrontCover"
InnerCover = "InnerCover"
Roundup = "Roundup"
Story = "Story"
Advertisement = "Advertisement"
Editorial = "Editorial"
Letters = "Letters"
Preview = "Preview"
BackCover = "BackCover"
Other = "Other"
Deleted = "Deleted"
"""
class PageInfo:
Image = 0
Type = PageType.Story
DoublePage = False
ImageSize = 0
Key = ""
ImageWidth = 0
ImageHeight = 0
"""
class GenericMetadata:
def __init__(self):
self.isEmpty = True
self.tagOrigin = None
self.series = None
self.issue = None
self.title = None
self.publisher = None
self.month = None
self.year = None
self.day = None
self.issueCount = None
self.volume = None
self.genre = None
self.language = None # 2 letter iso code
self.comments = None # use same way as Summary in CIX
self.volumeCount = None
self.criticalRating = None
self.country = None
self.alternateSeries = None
self.alternateNumber = None
self.alternateCount = None
self.imprint = None
self.notes = None
self.webLink = None
self.format = None
self.manga = None
self.blackAndWhite = None
self.pageCount = None
self.maturityRating = None
self.storyArc = None
self.seriesGroup = None
self.scanInfo = None
self.characters = None
self.teams = None
self.locations = None
self.credits = list()
self.tags = list()
self.pages = list()
# Some CoMet-only items
self.price = None
self.isVersionOf = None
self.rights = None
self.identifier = None
self.lastMark = None
self.coverImage = None
def overlay( self, new_md ):
# Overlay a metadata object on this one
# that is, when the new object has non-None
# values, over-write them to this one
def assign( cur, new ):
if new is not None:
if type(new) == str and len(new) == 0:
setattr(self, cur, None)
else:
setattr(self, cur, new)
if not new_md.isEmpty:
self.isEmpty = False
assign( 'series', new_md.series )
assign( "issue", new_md.issue )
assign( "issueCount", new_md.issueCount )
assign( "title", new_md.title )
assign( "publisher", new_md.publisher )
assign( "day", new_md.day )
assign( "month", new_md.month )
assign( "year", new_md.year )
assign( "volume", new_md.volume )
assign( "volumeCount", new_md.volumeCount )
assign( "genre", new_md.genre )
assign( "language", new_md.language )
assign( "country", new_md.country )
assign( "criticalRating", new_md.criticalRating )
assign( "alternateSeries", new_md.alternateSeries )
assign( "alternateNumber", new_md.alternateNumber )
assign( "alternateCount", new_md.alternateCount )
assign( "imprint", new_md.imprint )
assign( "webLink", new_md.webLink )
assign( "format", new_md.format )
assign( "manga", new_md.manga )
assign( "blackAndWhite", new_md.blackAndWhite )
assign( "maturityRating", new_md.maturityRating )
assign( "storyArc", new_md.storyArc )
assign( "seriesGroup", new_md.seriesGroup )
assign( "scanInfo", new_md.scanInfo )
assign( "characters", new_md.characters )
assign( "teams", new_md.teams )
assign( "locations", new_md.locations )
assign( "comments", new_md.comments )
assign( "notes", new_md.notes )
assign( "price", new_md.price )
assign( "isVersionOf", new_md.isVersionOf )
assign( "rights", new_md.rights )
assign( "identifier", new_md.identifier )
assign( "lastMark", new_md.lastMark )
self.overlayCredits( new_md.credits )
# TODO
# not sure if the tags and pages should broken down, or treated
# as whole lists....
# For now, go the easy route, where any overlay
# value wipes out the whole list
if len(new_md.tags) > 0:
assign( "tags", new_md.tags )
if len(new_md.pages) > 0:
assign( "pages", new_md.pages )
def overlayCredits( self, new_credits ):
for c in new_credits:
if c.has_key('primary') and c['primary']:
primary = True
else:
primary = False
# Remove credit role if person is blank
if c['person'] == "":
for r in reversed(self.credits):
if r['role'].lower() == c['role'].lower():
self.credits.remove(r)
# otherwise, add it!
else:
self.addCredit( c['person'], c['role'], primary )
def setDefaultPageList( self, count ):
# generate a default page list, with the first page marked as the cover
for i in range(count):
page_dict = dict()
page_dict['Image'] = str(i)
if i == 0:
page_dict['Type'] = PageType.FrontCover
self.pages.append( page_dict )
def getArchivePageIndex( self, pagenum ):
# convert the displayed page number to the page index of the file in the archive
if pagenum < len( self.pages ):
return int( self.pages[pagenum]['Image'] )
else:
return 0
def getCoverPageIndexList( self ):
# return a list of archive page indices of cover pages
coverlist = []
for p in self.pages:
if 'Type' in p and p['Type'] == PageType.FrontCover:
coverlist.append( int(p['Image']))
if len(coverlist) == 0:
coverlist.append( 0 )
return coverlist
def addCredit( self, person, role, primary = False ):
credit = dict()
credit['person'] = person
credit['role'] = role
if primary:
credit['primary'] = primary
# look to see if it's not already there...
found = False
for c in self.credits:
if ( c['person'].lower() == person.lower() and
c['role'].lower() == role.lower() ):
# no need to add it. just adjust the "primary" flag as needed
c['primary'] = primary
found = True
break
if not found:
self.credits.append(credit)
def __str__( self ):
vals = []
if self.isEmpty:
return "No metadata"
def add_string( tag, val ):
if val is not None and u"{0}".format(val) != "":
vals.append( (tag, val) )
def add_attr_string( tag ):
val = getattr(self,tag)
add_string( tag, getattr(self,tag) )
add_attr_string( "series" )
add_attr_string( "issue" )
add_attr_string( "issueCount" )
add_attr_string( "title" )
add_attr_string( "publisher" )
add_attr_string( "year" )
add_attr_string( "month" )
add_attr_string( "day" )
add_attr_string( "volume" )
add_attr_string( "volumeCount" )
add_attr_string( "genre" )
add_attr_string( "language" )
add_attr_string( "country" )
add_attr_string( "criticalRating" )
add_attr_string( "alternateSeries" )
add_attr_string( "alternateNumber" )
add_attr_string( "alternateCount" )
add_attr_string( "imprint" )
add_attr_string( "webLink" )
add_attr_string( "format" )
add_attr_string( "manga" )
add_attr_string( "price" )
add_attr_string( "isVersionOf" )
add_attr_string( "rights" )
add_attr_string( "identifier" )
add_attr_string( "lastMark" )
if self.blackAndWhite:
add_attr_string( "blackAndWhite" )
add_attr_string( "maturityRating" )
add_attr_string( "storyArc" )
add_attr_string( "seriesGroup" )
add_attr_string( "scanInfo" )
add_attr_string( "characters" )
add_attr_string( "teams" )
add_attr_string( "locations" )
add_attr_string( "comments" )
add_attr_string( "notes" )
add_string( "tags", utils.listToString( self.tags ) )
for c in self.credits:
primary = ""
if c.has_key('primary') and c['primary']:
primary = " [P]"
add_string( "credit", c['role']+": "+c['person'] + primary)
# find the longest field name
flen = 0
for i in vals:
flen = max( flen, len(i[0]) )
flen += 1
#format the data nicely
outstr = ""
fmt_str = u"{0: <" + str(flen) + "} {1}\n"
for i in vals:
outstr += fmt_str.format( i[0]+":", i[1] )
return outstr

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1,198 @@
"""
A python class to manage fetching and caching of images by URL
"""
"""
Copyright 2012-2014 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 sqlite3 as lite
import os
import datetime
import shutil
import tempfile
import urllib2, urllib
try:
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
from PyQt4 import QtGui
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject():
def __init__(self,*args):
pass
class QByteArray():
pass
class pyqtSignal():
def __init__(self,*args):
pass
def emit(a,b,c):
pass
from settings import ComicTaggerSettings
class ImageFetcherException(Exception):
pass
class ImageFetcher(QObject):
fetchComplete = pyqtSignal( QByteArray , int)
def __init__(self ):
QObject.__init__(self)
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join( self.settings_folder, "image_url_cache.db" )
self.cache_folder = os.path.join( self.settings_folder, "image_cache" )
if not os.path.exists( self.db_file ):
self.create_image_db()
def clearCache( self ):
os.unlink( self.db_file )
if os.path.isdir( self.cache_folder ):
shutil.rmtree( self.cache_folder )
def fetch( self, url, user_data=None, blocking=False ):
"""
If called with blocking=True, this will block until the image is
fetched.
If called with blocking=False, this will run the fetch in the
background, and emit a signal when done
"""
self.user_data = user_data
self.fetched_url = url
# first look in the DB
image_data = self.get_image_from_cache( url )
if blocking:
if image_data is None:
try:
image_data = urllib.urlopen(url).read()
except Exception as e:
print e
raise ImageFetcherException("Network Error!")
# save the image to the cache
self.add_image_to_cache( self.fetched_url, image_data )
return image_data
else:
# if we found it, just emit the signal asap
if image_data is not None:
self.fetchComplete.emit( QByteArray(image_data), self.user_data )
return
# didn't find it. look online
self.nam = QNetworkAccessManager()
self.nam.finished.connect(self.finishRequest)
self.nam.get(QNetworkRequest(QUrl(url)))
#we'll get called back when done...
def finishRequest(self, reply):
# read in the image data
image_data = reply.readAll()
# save the image to the cache
self.add_image_to_cache( self.fetched_url, image_data )
self.fetchComplete.emit( QByteArray(image_data), self.user_data )
def create_image_db( self ):
# this will wipe out any existing version
open( self.db_file, 'w').close()
# wipe any existing image cache folder too
if os.path.isdir( self.cache_folder ):
shutil.rmtree( self.cache_folder )
os.makedirs( self.cache_folder )
con = lite.connect( self.db_file )
# create tables
with con:
cur = con.cursor()
cur.execute("CREATE TABLE Images(" +
"url TEXT," +
"filename TEXT," +
"timestamp TEXT," +
"PRIMARY KEY (url) )"
)
def add_image_to_cache( self, url, image_data ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
tmp_fd, filename = tempfile.mkstemp(dir=self.cache_folder, prefix="img")
f = os.fdopen(tmp_fd, 'w+b')
f.write( image_data )
f.close()
cur.execute("INSERT or REPLACE INTO Images VALUES( ?, ?, ? )" ,
(url,
filename,
timestamp )
)
def get_image_from_cache( self, url ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
cur.execute("SELECT filename FROM Images WHERE url=?", [ url ])
row = cur.fetchone()
if row is None :
return None
else:
filename = row[0]
image_data = None
try:
with open( filename, 'rb' ) as f:
image_data = f.read()
f.close()
except IOError as e:
pass
return image_data

199
lib/comictaggerlib/imagehasher.py Executable file
View File

@ -0,0 +1,199 @@
"""
A pthyon class to manage creating image content hashes, and calculate hamming distances
"""
"""
Copyright 2013 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 StringIO
import sys
try:
from PIL import Image
from PIL import WebPImagePlugin
pil_available = True
except ImportError:
pil_available = False
class ImageHasher(object):
def __init__(self, path=None, data=None, width=8, height=8):
#self.hash_size = size
self.width = width
self.height = height
if path is None and data is None:
raise IOError
else:
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(StringIO.StringIO(data))
except:
print "Image data seems corrupted!"
# just generate a bogus image
self.image = Image.new( "L", (1,1))
def average_hash(self):
try:
image = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert("L")
except Exception as e:
sys.exc_clear()
print "average_hash error:", e
return long(0)
pixels = list(image.getdata())
avg = sum(pixels) / len(pixels)
def compare_value_to_avg(i):
return ( 1 if i > avg else 0 )
bitlist = map(compare_value_to_avg, pixels)
# build up an int value from the bit list, one bit at a time
def set_bit( x, (idx, val) ):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), 0)
#print "{0:016x}".format(result)
return result
def average_hash2( self ):
pass
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d'
# works!
from numpy import array
from scipy.signal import convolve2d
im = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert('L')
in_data = array((im.getdata())).reshape(self.width, self.height)
filt = array([[0,1,0],[1,-4,1],[0,1,0]])
filt_data = convolve2d(in_data,filt,mode='same',boundary='symm').flatten()
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
#print "{0:016x}".format(result)
return result
"""
def dct_average_hash(self):
pass
"""
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
1. Reduce size. Like Average Hash, pHash starts with a small image.
However, the image is larger than 8x8; 32x32 is a good size. This
is really done to simplify the DCT computation and not because it
is needed to reduce the high frequencies.
2. Reduce color. The image is reduced to a grayscale just to further
simplify the number of computations.
3. Compute the DCT. The DCT separates the image into a collection of
frequencies and scalars. While JPEG uses an 8x8 DCT, this algorithm
uses a 32x32 DCT.
4. Reduce the DCT. This is the magic step. While the DCT is 32x32,
just keep the top-left 8x8. Those represent the lowest frequencies in
the picture.
5. Compute the average value. Like the Average Hash, compute the mean DCT
value (using only the 8x8 DCT low-frequency values and excluding the first
term since the DC coefficient can be significantly different from the other
values and will throw off the average). Thanks to David Starkweather for the
added information about pHash. He wrote: "the dct hash is based on the low 2D
DCT coefficients starting at the second from lowest, leaving out the first DC
term. This excludes completely flat image information (i.e. solid colors) from
being included in the hash description."
6. Further reduce the DCT. This is the magic step. Set the 64 hash bits to 0 or
1 depending on whether each of the 64 DCT values is above or below the average
value. The result doesn't tell us the actual low frequencies; it just tells us
the very-rough relative scale of the frequencies to the mean. The result will not
vary as long as the overall structure of the image remains the same; this can
survive gamma and color histogram adjustments without a problem.
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
matter, just as long as you are consistent.
"""
"""
import numpy
import scipy.fftpack
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
# Step 1,2
im = self.image.resize((32, 32), Image.ANTIALIAS).convert("L")
in_data = numpy.asarray(im)
# Step 3
dct = scipy.fftpack.dct( in_data.astype(float) )
# Step 4
# Just skip the top and left rows when slicing, as suggested somewhere else...
lofreq_dct = dct[1:9, 1:9].flatten()
# Step 5
avg = ( lofreq_dct.sum() ) / ( lofreq_dct.size )
median = numpy.median( lofreq_dct )
thresh = avg
# Step 6
def compare_value_to_thresh(i):
return ( 1 if i > thresh else 0 )
bitlist = map(compare_value_to_thresh, lofreq_dct)
#Step 7
def set_bit( x, (idx, val) ):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), long(0))
#print "{0:016x}".format(result)
return result
"""
#accepts 2 hashes (longs or hex strings) and returns the hamming distance
@staticmethod
def hamming_distance(h1, h2):
if type(h1) == long or type(h1) == int:
n1 = h1
n2 = h2
else:
# convert hex strings to ints
n1 = long( h1, 16)
n2 = long( h2, 16)
# xor the two numbers
n = n1 ^ n2
#count up the 1's in the binary string
return sum( b == '1' for b in bin(n)[2:] )

View File

@ -0,0 +1,86 @@
"""
A PyQT4 widget to display a popup image
"""
"""
Copyright 2012-2014 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 sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
class ImagePopup(QtGui.QDialog):
def __init__(self, parent, image_pixmap):
super(ImagePopup, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('imagepopup.ui' ), self)
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
#self.setWindowModality(QtCore.Qt.WindowModal)
self.setWindowFlags(QtCore.Qt.Popup)
self.setWindowState(QtCore.Qt.WindowFullScreen)
self.imagePixmap = image_pixmap
screen_size = QtGui.QDesktopWidget().screenGeometry()
self.resize(screen_size.width(), screen_size.height())
self.move( 0, 0)
# This is a total hack. Uses a snapshot of the desktop, and overlays a
# translucent screen over it. Probably can do it better by setting opacity of a
# widget
self.desktopBg = QtGui.QPixmap.grabWindow(QtGui.QApplication.desktop ().winId(),
0,0, screen_size.width(), screen_size.height())
bg = QtGui.QPixmap(ComicTaggerSettings.getGraphic('popup_bg.png'))
self.clientBgPixmap = bg.scaled(screen_size.width(), screen_size.height())
self.setMask(self.clientBgPixmap.mask())
self.applyImagePixmap()
self.showFullScreen()
self.raise_( )
QtGui.QApplication.restoreOverrideCursor()
def paintEvent (self, event):
self.painter = QtGui.QPainter(self)
self.painter.setRenderHint(QtGui.QPainter.Antialiasing)
self.painter.drawPixmap(0, 0, self.desktopBg)
self.painter.drawPixmap(0, 0, self.clientBgPixmap)
self.painter.end()
def applyImagePixmap( self ):
win_h = self.height()
win_w = self.width()
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(win_w, win_h, QtCore.Qt.KeepAspectRatio)
self.lblImage.setPixmap( display_pixmap )
else:
display_pixmap = self.imagePixmap
self.lblImage.setPixmap( display_pixmap )
# move and resize the label to be centered in the fame
img_w = display_pixmap.width()
img_h = display_pixmap.height()
self.lblImage.resize( img_w, img_h )
self.lblImage.move( (win_w - img_w)/2, (win_h - img_h)/2 )
def mousePressEvent( self , event):
self.close()

View File

@ -0,0 +1,652 @@
"""
A python class to automatically identify a comic archive
"""
"""
Copyright 2012-2014 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 sys
import math
import urllib2, urllib
import StringIO
try:
from PIL import Image
from PIL import WebPImagePlugin
pil_available = True
except ImportError:
pil_available = False
from settings import ComicTaggerSettings
from comicvinecacher import ComicVineCacher
from genericmetadata import GenericMetadata
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from imagehasher import ImageHasher
from imagefetcher import ImageFetcher, ImageFetcherException
from issuestring import IssueString
import utils
class IssueIdentifierNetworkError(Exception):
pass
class IssueIdentifierCancelled(Exception):
pass
class IssueIdentifier:
ResultNoMatches = 0
ResultFoundMatchButBadCoverScore = 1
ResultFoundMatchButNotFirstPage = 2
ResultMultipleMatchesWithBadImageScores = 3
ResultOneGoodMatch = 4
ResultMultipleGoodMatches = 5
def __init__(self, comic_archive, settings ):
self.comic_archive = comic_archive
self.image_hasher = 1
self.onlyUseAdditionalMetaData = False
# a decent hamming score, good enough to call it a match
self.min_score_thresh = 16
# for alternate covers, be more stringent, since we're a bit more scattershot in comparisons
self.min_alternate_score_thresh = 12
# the min distance a hamming score must be to separate itself from closest neighbor
self.min_score_distance = 4
# a very strong hamming score, almost certainly the same image
self.strong_score_thresh = 8
# used to eliminate series names that are too long based on our search string
self.length_delta_thresh = settings.id_length_delta_thresh
# used to eliminate unlikely publishers
self.publisher_blacklist = [ s.strip().lower() for s in settings.id_publisher_blacklist.split(',') ]
self.additional_metadata = GenericMetadata()
self.output_function = IssueIdentifier.defaultWriteOutput
self.callback = None
self.coverUrlCallback = None
self.search_result = self.ResultNoMatches
self.cover_page_index = 0
self.cancel = False
self.waitAndRetryOnRateLimit = False
def setScoreMinThreshold( self, thresh ):
self.min_score_thresh = thresh
def setScoreMinDistance( self, distance ):
self.min_score_distance = distance
def setAdditionalMetadata( self, md ):
self.additional_metadata = md
def setNameLengthDeltaThreshold( self, delta ):
self.length_delta_thresh = delta
def setPublisherBlackList( self, blacklist ):
self.publisher_blacklist = blacklist
def setHasherAlgorithm( self, algo ):
self.image_hasher = algo
pass
def setOutputFunction( self, func ):
self.output_function = func
pass
def calculateHash( self, image_data ):
if self.image_hasher == '3':
return ImageHasher( data=image_data ).dct_average_hash()
elif self.image_hasher == '2':
return ImageHasher( data=image_data ).average_hash2()
else:
return ImageHasher( data=image_data ).average_hash()
def getAspectRatio( self, image_data ):
try:
im = Image.open(StringIO.StringIO(image_data))
w,h = im.size
return float(h)/float(w)
except:
return 1.5
def cropCover( self, image_data ):
im = Image.open(StringIO.StringIO(image_data))
w,h = im.size
try:
cropped_im = im.crop( (int(w/2), 0, w, h) )
except Exception as e:
sys.exc_clear()
print "cropCover() error:", e
return None
output = StringIO.StringIO()
cropped_im.save(output, format="PNG")
cropped_image_data = output.getvalue()
output.close()
return cropped_image_data
def setProgressCallback( self, cb_func ):
self.callback = cb_func
def setCoverURLCallback( self, cb_func ):
self.coverUrlCallback = cb_func
def getSearchKeys( self ):
ca = self.comic_archive
search_keys = dict()
search_keys['series'] = None
search_keys['issue_number'] = None
search_keys['month'] = None
search_keys['year'] = None
search_keys['issue_count'] = None
if ca is None:
return
if self.onlyUseAdditionalMetaData:
search_keys['series'] = self.additional_metadata.series
search_keys['issue_number'] = self.additional_metadata.issue
search_keys['year'] = self.additional_metadata.year
search_keys['month'] = self.additional_metadata.month
search_keys['issue_count'] = self.additional_metadata.issueCount
return search_keys
# see if the archive has any useful meta data for searching with
if ca.hasCIX():
internal_metadata = ca.readCIX()
elif ca.hasCBI():
internal_metadata = ca.readCBI()
else:
internal_metadata = ca.readCBI()
# try to get some metadata from filename
md_from_filename = ca.metadataFromFilename()
# preference order:
#1. Additional metadata
#1. Internal metadata
#1. Filename metadata
if self.additional_metadata.series is not None:
search_keys['series'] = self.additional_metadata.series
elif internal_metadata.series is not None:
search_keys['series'] = internal_metadata.series
else:
search_keys['series'] = md_from_filename.series
if self.additional_metadata.issue is not None:
search_keys['issue_number'] = self.additional_metadata.issue
elif internal_metadata.issue is not None:
search_keys['issue_number'] = internal_metadata.issue
else:
search_keys['issue_number'] = md_from_filename.issue
if self.additional_metadata.year is not None:
search_keys['year'] = self.additional_metadata.year
elif internal_metadata.year is not None:
search_keys['year'] = internal_metadata.year
else:
search_keys['year'] = md_from_filename.year
if self.additional_metadata.month is not None:
search_keys['month'] = self.additional_metadata.month
elif internal_metadata.month is not None:
search_keys['month'] = internal_metadata.month
else:
search_keys['month'] = md_from_filename.month
if self.additional_metadata.issueCount is not None:
search_keys['issue_count'] = self.additional_metadata.issueCount
elif internal_metadata.issueCount is not None:
search_keys['issue_count'] = internal_metadata.issueCount
else:
search_keys['issue_count'] = md_from_filename.issueCount
return search_keys
@staticmethod
def defaultWriteOutput( text ):
sys.stdout.write( text )
sys.stdout.flush()
def log_msg( self, msg , newline=True ):
self.output_function(msg)
if newline:
self.output_function("\n")
def getIssueCoverMatchScore( self, comicVine, issue_id, primary_img_url, primary_thumb_url, page_url, localCoverHashList, useRemoteAlternates = False , useLog=True):
# localHashes is a list of pre-calculated hashs.
# useRemoteAlternates - indicates to use alternate covers from CV
try:
url_image_data = ImageFetcher().fetch(primary_thumb_url, blocking=True)
except ImageFetcherException:
self.log_msg( "Network issue while fetching cover image from ComicVine. Aborting...")
raise IssueIdentifierNetworkError
if self.cancel == True:
raise IssueIdentifierCancelled
# alert the GUI, if needed
if self.coverUrlCallback is not None:
self.coverUrlCallback( url_image_data )
remote_cover_list = []
item = dict()
item['url'] = primary_img_url
item['hash'] = self.calculateHash( url_image_data )
remote_cover_list.append( item )
if self.cancel == True:
raise IssueIdentifierCancelled
if useRemoteAlternates:
alt_img_url_list = comicVine.fetchAlternateCoverURLs( issue_id, page_url )
for alt_url in alt_img_url_list:
try:
alt_url_image_data = ImageFetcher().fetch(alt_url, blocking=True)
except ImageFetcherException:
self.log_msg( "Network issue while fetching alt. cover image from ComicVine. Aborting...")
raise IssueIdentifierNetworkError
if self.cancel == True:
raise IssueIdentifierCancelled
# alert the GUI, if needed
if self.coverUrlCallback is not None:
self.coverUrlCallback( alt_url_image_data )
item = dict()
item['url'] = alt_url
item['hash'] = self.calculateHash( alt_url_image_data )
remote_cover_list.append( item )
if self.cancel == True:
raise IssueIdentifierCancelled
if useLog and useRemoteAlternates:
self.log_msg( "[{0} alt. covers]".format(len(remote_cover_list)-1), False )
if useLog:
self.log_msg( "[ ", False )
score_list = []
done = False
for local_cover_hash in localCoverHashList:
for remote_cover_item in remote_cover_list:
score = ImageHasher.hamming_distance(local_cover_hash, remote_cover_item['hash'] )
score_item = dict()
score_item['score'] = score
score_item['url'] = remote_cover_item['url']
score_item['hash'] = remote_cover_item['hash']
score_list.append( score_item )
if useLog:
self.log_msg( "{0} ".format(score), False )
if score <= self.strong_score_thresh:
# such a good score, we can quit now, since for sure we have a winner
done = True
break
if done:
break
if useLog:
self.log_msg( " ]", False )
best_score_item = min(score_list, key=lambda x:x['score'])
return best_score_item
"""
def validate( self, issue_id ):
# create hash list
score = self.getIssueMatchScore( issue_id, hash_list, useRemoteAlternates = True )
if score < 20:
return True
else:
return False
"""
def search( self ):
ca = self.comic_archive
self.match_list = []
self.cancel = False
self.search_result = self.ResultNoMatches
if not pil_available:
self.log_msg( "Python Imaging Library (PIL) is not available and is needed for issue identification." )
return self.match_list
if not ca.seemsToBeAComicArchive():
self.log_msg( "Sorry, but "+ opts.filename + " is not a comic archive!")
return self.match_list
cover_image_data = ca.getPage( self.cover_page_index )
cover_hash = self.calculateHash( cover_image_data )
#check the apect ratio
# if it's wider than it is high, it's probably a two page spread
# if so, crop it and calculate a second hash
narrow_cover_hash = None
aspect_ratio = self.getAspectRatio( cover_image_data )
if aspect_ratio < 1.0:
right_side_image_data = self.cropCover( cover_image_data )
if right_side_image_data is not None:
narrow_cover_hash = self.calculateHash( right_side_image_data )
#self.log_msg( "Cover hash = {0:016x}".format(cover_hash) )
keys = self.getSearchKeys()
#normalize the issue number
keys['issue_number'] = IssueString(keys['issue_number']).asString()
# we need, at minimum, a series and issue number
if keys['series'] is None or keys['issue_number'] is None:
self.log_msg("Not enough info for a search!")
return []
self.log_msg( "Going to search for:" )
self.log_msg( "\tSeries: " + keys['series'] )
self.log_msg( "\tIssue : " + keys['issue_number'] )
if keys['issue_count'] is not None:
self.log_msg( "\tCount : " + str(keys['issue_count']) )
if keys['year'] is not None:
self.log_msg( "\tYear : " + str(keys['year']) )
if keys['month'] is not None:
self.log_msg( "\tMonth : " + str(keys['month']) )
#self.log_msg("Publisher Blacklist: " + str(self.publisher_blacklist))
comicVine = ComicVineTalker( )
comicVine.wait_for_rate_limit = self.waitAndRetryOnRateLimit
comicVine.setLogFunc( self.output_function )
#self.log_msg( ( "Searching for " + keys['series'] + "...")
self.log_msg( u"Searching for {0} #{1} ...".format( keys['series'], keys['issue_number']) )
try:
cv_search_results = comicVine.searchForSeries( keys['series'] )
except ComicVineTalkerException:
self.log_msg( "Network issue while searching for series. Aborting...")
return []
#self.log_msg( "Found " + str(len(cv_search_results)) + " initial results" )
if self.cancel == True:
return []
if cv_search_results == None:
return []
series_second_round_list = []
#self.log_msg( "Removing results with too long names, banned publishers, or future start dates" )
for item in cv_search_results:
length_approved = False
publisher_approved = True
date_approved = True
# remove any series that starts after the issue year
if keys['year'] is not None and str(keys['year']).isdigit() and item['start_year'] is not None and str(item['start_year']).isdigit():
if int(keys['year']) < int(item['start_year']):
date_approved = False
#assume that our search name is close to the actual name, say within ,e.g. 5 chars
shortened_key = utils.removearticles(keys['series'])
shortened_item_name = utils.removearticles(item['name'])
if len( shortened_item_name ) < ( len( shortened_key ) + self.length_delta_thresh) :
length_approved = True
# remove any series from publishers on the blacklist
if item['publisher'] is not None:
publisher = item['publisher']['name']
if publisher is not None and publisher.lower() in self.publisher_blacklist:
publisher_approved = False
if length_approved and publisher_approved and date_approved:
series_second_round_list.append(item)
self.log_msg( "Searching in " + str(len(series_second_round_list)) +" series" )
if self.callback is not None:
self.callback( 0, len(series_second_round_list))
# now sort the list by name length
series_second_round_list.sort(key=lambda x: len(x['name']), reverse=False)
#build a list of volume IDs
volume_id_list = list()
for series in series_second_round_list:
volume_id_list.append( series['id'])
try:
issue_list = comicVine.fetchIssuesByVolumeIssueNumAndYear( volume_id_list,
keys['issue_number'],
keys['year'])
except ComicVineTalkerException:
self.log_msg( "Network issue while searching for series details. Aborting...")
return []
if issue_list is None:
return []
shortlist = list()
#now re-associate the issues and volumes
for issue in issue_list:
for series in series_second_round_list:
if series['id'] == issue['volume']['id']:
shortlist.append( (series, issue) )
break
if keys['year'] is None:
self.log_msg( u"Found {0} series that have an issue #{1}".format(len(shortlist), keys['issue_number']) )
else:
self.log_msg( u"Found {0} series that have an issue #{1} from {2}".format(len(shortlist), keys['issue_number'], keys['year'] ))
# now we have a shortlist of volumes with the desired issue number
# Do first round of cover matching
counter = len(shortlist)
for series, issue in shortlist:
if self.callback is not None:
self.callback( counter, len(shortlist)*3)
counter += 1
self.log_msg( u"Examining covers for ID: {0} {1} ({2}) ...".format(
series['id'],
series['name'],
series['start_year']), newline=False )
# parse out the cover date
day, month, year = comicVine.parseDateStr( issue['cover_date'] )
# Now check the cover match against the primary image
hash_list = [ cover_hash ]
if narrow_cover_hash is not None:
hash_list.append(narrow_cover_hash)
try:
image_url = issue['image']['super_url']
thumb_url = issue['image']['thumb_url']
page_url = issue['site_detail_url']
score_item = self.getIssueCoverMatchScore( comicVine, issue['id'], image_url, thumb_url, page_url, hash_list, useRemoteAlternates = False )
except:
self.match_list = []
return self.match_list
match = dict()
match['series'] = u"{0} ({1})".format(series['name'], series['start_year'])
match['distance'] = score_item['score']
match['issue_number'] = keys['issue_number']
match['cv_issue_count'] = series['count_of_issues']
match['url_image_hash'] = score_item['hash']
match['issue_title'] = issue['name']
match['issue_id'] = issue['id']
match['volume_id'] = series['id']
match['month'] = month
match['year'] = year
match['publisher'] = None
if series['publisher'] is not None:
match['publisher'] = series['publisher']['name']
match['image_url'] = image_url
match['thumb_url'] = thumb_url
match['page_url'] = page_url
match['description'] = issue['description']
self.match_list.append(match)
self.log_msg( " --> {0}".format(match['distance']), newline=False )
self.log_msg( "" )
if len(self.match_list) == 0:
self.log_msg( ":-( no matches!" )
self.search_result = self.ResultNoMatches
return self.match_list
# sort list by image match scores
self.match_list.sort(key=lambda k: k['distance'])
l = []
for i in self.match_list:
l.append( i['distance'] )
self.log_msg( "Compared to covers in {0} issue(s):".format(len(self.match_list)), newline=False)
self.log_msg( str(l))
def print_match(item):
self.log_msg( u"-----> {0} #{1} {2} ({3}/{4}) -- score: {5}".format(
item['series'],
item['issue_number'],
item['issue_title'],
item['month'],
item['year'],
item['distance']) )
best_score = self.match_list[0]['distance']
if best_score >= self.min_score_thresh:
# we have 1 or more low-confidence matches (all bad cover scores)
# look at a few more pages in the archive, and also alternate covers online
self.log_msg( "Very weak scores for the cover. Analyzing alternate pages and covers..." )
hash_list = [ cover_hash ]
if narrow_cover_hash is not None:
hash_list.append(narrow_cover_hash)
for i in range( 1, min(3, ca.getNumberOfPages())):
image_data = ca.getPage(i)
page_hash = self.calculateHash( image_data )
hash_list.append( page_hash )
second_match_list = []
counter = 2*len(self.match_list)
for m in self.match_list:
if self.callback is not None:
self.callback( counter, len(self.match_list)*3)
counter += 1
self.log_msg( u"Examining alternate covers for ID: {0} {1} ...".format(
m['volume_id'],
m['series']), newline=False )
try:
score_item = self.getIssueCoverMatchScore( comicVine, m['issue_id'], m['image_url'], m['thumb_url'], m['page_url'], hash_list, useRemoteAlternates = True )
except:
self.match_list = []
return self.match_list
self.log_msg("--->{0}".format(score_item['score']))
self.log_msg( "" )
if score_item['score'] < self.min_alternate_score_thresh:
second_match_list.append(m)
m['distance'] = score_item['score']
if len( second_match_list ) == 0:
if len( self.match_list) == 1:
self.log_msg( "No matching pages in the issue." )
self.log_msg( u"--------------------------------------------------")
print_match(self.match_list[0])
self.log_msg( u"--------------------------------------------------")
self.search_result = self.ResultFoundMatchButBadCoverScore
else:
self.log_msg( u"--------------------------------------------------")
self.log_msg( u"Multiple bad cover matches! Need to use other info..." )
self.log_msg( u"--------------------------------------------------")
self.search_result = self.ResultMultipleMatchesWithBadImageScores
return self.match_list
else:
# We did good, found something!
self.log_msg( "Success in secondary/alternate cover matching!" )
self.match_list = second_match_list
# sort new list by image match scores
self.match_list.sort(key=lambda k: k['distance'])
best_score = self.match_list[0]['distance']
self.log_msg("[Second round cover matching: best score = {0}]".format(best_score))
# now drop down into the rest of the processing
if self.callback is not None:
self.callback( 99, 100)
#now pare down list, remove any item more than specified distant from the top scores
for item in reversed(self.match_list):
if item['distance'] > best_score + self.min_score_distance:
self.match_list.remove(item)
# One more test for the case choosing limited series first issue vs a trade with the same cover:
# if we have a given issue count > 1 and the volume from CV has count==1, remove it from match list
if len(self.match_list) >= 2 and keys['issue_count'] is not None and keys['issue_count'] != 1:
new_list = list()
for match in self.match_list:
if match['cv_issue_count'] != 1:
new_list.append(match)
else:
self.log_msg("Removing volume {0} [{1}] from consideration (only 1 issue)".format(match['series'], match['volume_id']))
if len(new_list) > 0:
self.match_list = new_list
if len(self.match_list) == 1:
self.log_msg( u"--------------------------------------------------")
print_match(self.match_list[0])
self.log_msg( u"--------------------------------------------------")
self.search_result = self.ResultOneGoodMatch
elif len(self.match_list) == 0:
self.log_msg( u"--------------------------------------------------")
self.log_msg( "No matches found :(" )
self.log_msg( u"--------------------------------------------------")
self.search_result = self.ResultNoMatches
else:
# we've got multiple good matches:
self.log_msg( "More than one likley candiate." )
self.search_result = self.ResultMultipleGoodMatches
self.log_msg( u"--------------------------------------------------")
for item in self.match_list:
print_match(item)
self.log_msg( u"--------------------------------------------------")
return self.match_list

View File

@ -0,0 +1,177 @@
"""
A PyQT4 dialog to select specific issue from list
"""
"""
Copyright 2012-2014 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 sys
import os
import re
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from issuestring import IssueString
from coverimagewidget import CoverImageWidget
import utils
class IssueNumberTableWidgetItem(QtGui.QTableWidgetItem):
def __lt__(self, other):
selfStr = self.data(QtCore.Qt.DisplayRole).toString()
otherStr = other.data(QtCore.Qt.DisplayRole).toString()
return (IssueString(selfStr).asFloat() <
IssueString(otherStr).asFloat())
class IssueSelectionWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, settings, series_id, issue_number):
super(IssueSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('issueselectionwindow.ui' ), self)
self.coverWidget = CoverImageWidget( self.coverImageContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.coverImageContainer )
gridlayout.addWidget( self.coverWidget )
gridlayout.setContentsMargins(0,0,0,0)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.series_id = series_id
self.settings = settings
self.url_fetch_thread = None
if issue_number is None or issue_number == "":
self.issue_number = 1
else:
self.issue_number = issue_number
self.initial_id = None
self.performQuery()
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
#now that the list has been sorted, find the initial record, and select it
if self.initial_id is None:
self.twList.selectRow( 0 )
else:
for r in range(0, self.twList.rowCount()):
issue_id, b = self.twList.item( r, 0 ).data( QtCore.Qt.UserRole ).toInt()
if (issue_id == self.initial_id):
self.twList.selectRow( r )
break
def performQuery( self ):
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
try:
comicVine = ComicVineTalker()
volume_data = comicVine.fetchVolumeData( self.series_id )
self.issue_list = comicVine.fetchIssuesByVolume( self.series_id )
except ComicVineTalkerException as e:
QtGui.QApplication.restoreOverrideCursor()
if e.code == ComicVineTalkerException.RateLimit:
QtGui.QMessageBox.critical(self, self.tr("Comic Vine Error"), ComicVineTalker.getRateLimitMessage())
else:
QtGui.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to ComicVine to list issues!"))
return
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
row = 0
for record in self.issue_list:
self.twList.insertRow(row)
item_text = record['issue_number']
item = IssueNumberTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole ,record['id'])
item.setData(QtCore.Qt.DisplayRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = record['cover_date']
if item_text is None:
item_text = ""
#remove the day of "YYYY-MM-DD"
parts = item_text.split("-")
if len(parts) > 1:
item_text = parts[0] + "-" + parts[1]
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
item_text = record['name']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if IssueString(record['issue_number']).asString().lower() == IssueString(self.issue_number).asString().lower():
self.initial_id = record['id']
row += 1
self.twList.setSortingEnabled(True)
self.twList.sortItems( 0 , QtCore.Qt.AscendingOrder )
QtGui.QApplication.restoreOverrideCursor()
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.issue_id, b = self.twList.item( curr.row(), 0 ).data( QtCore.Qt.UserRole ).toInt()
# list selection was changed, update the the issue cover
for record in self.issue_list:
if record['id'] == self.issue_id:
self.issue_number = record['issue_number']
self.coverWidget.setIssueID( int(self.issue_id) )
if record['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( record['description'] )
break

View File

@ -0,0 +1,134 @@
"""
Class for handling the odd permutations of an 'issue number' that the comics industry throws at us
e.g.:
"12"
"12.1"
"0"
"-1"
"5AU"
"100-2"
"""
"""
Copyright 2012-2014 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 utils
import math
import re
class IssueString:
def __init__(self, text):
# break up the issue number string into 2 parts: the numeric and suffix string.
# ( assumes that the numeric portion is always first )
self.num = None
self.suffix = ""
if text is None:
return
if type(text) == int:
text = str(text)
if len(text) == 0:
return
text = unicode(text)
#skip the minus sign if it's first
if text[0] == '-':
start = 1
else:
start = 0
# if it's still not numeric at start skip it
if text[start].isdigit() or text[start] == ".":
# walk through the string, look for split point (the first non-numeric)
decimal_count = 0
for idx in range( start, len(text) ):
if text[idx] not in "0123456789.":
break
# special case: also split on second "."
if text[idx] == ".":
decimal_count += 1
if decimal_count > 1:
break
else:
idx = len(text)
# move trailing numeric decimal to suffix
# (only if there is other junk after )
if text[idx-1] == "." and len(text) != idx:
idx = idx -1
# if there is no numeric after the minus, make the minus part of the suffix
if idx == 1 and start == 1:
idx = 0
part1 = text[0:idx]
part2 = text[idx:len(text)]
if part1 != "":
self.num = float( part1 )
self.suffix = part2
else:
self.suffix = text
#print "num: {0} suf: {1}".format(self.num, self.suffix)
def asString( self, pad = 0 ):
#return the float, left side zero-padded, with suffix attached
if self.num is None:
return self.suffix
negative = self.num < 0
num_f = abs(self.num)
num_int = int( num_f )
num_s = str( num_int )
if float( num_int ) != num_f:
num_s = str( num_f )
num_s += self.suffix
# create padding
padding = ""
l = len( str(num_int))
if l < pad :
padding = "0" * (pad - l)
num_s = padding + num_s
if negative:
num_s = "-" + num_s
return num_s
def asFloat( self ):
#return the float, with no suffix
return self.num
def asInt( self ):
#return the int version of the float
if self.num is None:
return None
return int( self.num )

View File

@ -0,0 +1,40 @@
"""
A PyQT4 dialog to a text file or log
"""
"""
Copyright 2012-2014 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 sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
class LogWindow(QtGui.QDialog):
def __init__(self, parent):
super(LogWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('logwindow.ui' ), self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
def setText( self, text ):
self.textEdit.setPlainText( text )

91
lib/comictaggerlib/main.py Executable file
View File

@ -0,0 +1,91 @@
"""
A python app to (automatically) tag comic archives
"""
"""
Copyright 2012-2014 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 sys
import signal
import os
import traceback
import platform
import utils
import cli
from settings import ComicTaggerSettings
from options import Options
from comicvinetalker import ComicVineTalker
try:
qt_available = True
from PyQt4 import QtCore, QtGui
from taggerwindow import TaggerWindow
except ImportError as e:
qt_available = False
#---------------------------------------
def ctmain():
utils.fix_output_encoding()
settings = ComicTaggerSettings()
opts = Options()
opts.parseCmdLineArgs()
# manage the CV API key
if opts.cv_api_key:
if opts.cv_api_key != settings.cv_api_key:
settings.cv_api_key = opts.cv_api_key
settings.save()
if opts.only_set_key:
print "Key set"
return
ComicVineTalker.api_key = settings.cv_api_key
signal.signal(signal.SIGINT, signal.SIG_DFL)
if not qt_available and not opts.no_gui:
opts.no_gui = True
print >> sys.stderr, "PyQt4 is not available. ComicTagger is limited to command-line mode."
if opts.no_gui:
cli.cli_mode( opts, settings )
else:
app = QtGui.QApplication(sys.argv)
if platform.system() != "Linux":
img = QtGui.QPixmap(ComicTaggerSettings.getGraphic('tags.png'))
splash = QtGui.QSplashScreen(img)
splash.show()
splash.raise_()
app.processEvents()
try:
tagger_window = TaggerWindow( opts.file_list, settings, opts=opts )
tagger_window.show()
if platform.system() != "Linux":
splash.finish( tagger_window )
sys.exit(app.exec_())
except Exception, e:
QtGui.QMessageBox.critical(QtGui.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc() )

View File

@ -0,0 +1,160 @@
"""
A PyQT4 dialog to select from automated issue matches
"""
"""
Copyright 2012-2014 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 sys
import os
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from comicarchive import MetaDataStyle
from coverimagewidget import CoverImageWidget
from comicvinetalker import ComicVineTalker
import utils
class MatchSelectionWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, matches, comic_archive):
super(MatchSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('matchselectionwindow.ui' ), self)
self.altCoverWidget = CoverImageWidget( self.altCoverContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.altCoverContainer )
gridlayout.addWidget( self.altCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.matches = matches
self.comic_archive = comic_archive
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.updateData()
def updateData( self):
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow( 0 )
path = self.comic_archive.path
self.setWindowTitle( u"Select correct match: {0}".format(
os.path.split(path)[1] ))
def populateTable( self ):
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
row = 0
for match in self.matches:
self.twList.insertRow(row)
item_text = match['series']
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match['publisher'] is not None:
item_text = u"{0}".format(match['publisher'])
else:
item_text = u"Unknown"
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = u""
year_str = u"????"
if match['month'] is not None:
month_str = u"-{0:02d}".format(int(match['month']))
if match['year'] is not None:
year_str = u"{0}".format(match['year'])
item_text = year_str + month_str
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match['issue_title']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems( 2 , QtCore.Qt.AscendingOrder )
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.setIssueID( self.currentMatch()['issue_id'] )
if self.currentMatch()['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( self.currentMatch()['description'] )
def setCoverImage( self ):
self.archiveCoverWidget.setArchive( self.comic_archive)
def currentMatch( self ):
row = self.twList.currentRow()
match = self.twList.item(row, 0).data( QtCore.Qt.UserRole ).toPyObject()[0]
return match

View File

@ -0,0 +1,106 @@
"""
A PyQt4 dialog to show a message and let the user check a box
"""
"""
Copyright 2012-2014 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.
"""
"""
example usage:
checked = OptionalMessageDialog.msg( self, "Disclaimer",
"This is beta software, and you are using it at your own risk!",
)
said_yes, checked = OptionalMessageDialog.question( self, "Question",
"Are you sure you wish to do this?",
)
"""
from PyQt4.QtCore import *
from PyQt4.QtGui import *
StyleMessage = 0
StyleQuestion = 1
class OptionalMessageDialog(QDialog):
def __init__(self, parent, style, title, msg, check_state=Qt.Unchecked, check_text=None ):
QDialog.__init__(self, parent)
self.setWindowTitle( title )
self.was_accepted = False
l = QVBoxLayout( self )
self.theLabel = QLabel( msg )
self.theLabel.setWordWrap(True)
self.theLabel.setTextFormat( Qt.RichText )
self.theLabel.setOpenExternalLinks(True)
self.theLabel.setTextInteractionFlags( Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard )
l.addWidget( self.theLabel )
l.insertSpacing ( -1, 10 )
if check_text is None:
if style == StyleQuestion:
check_text = "Remember this answer"
else:
check_text = "Don't show this message again"
self.theCheckBox = QCheckBox(check_text)
self.theCheckBox.setCheckState( check_state )
l.addWidget( self.theCheckBox )
btnbox_style = QDialogButtonBox.Ok
if style == StyleQuestion:
btnbox_style = QDialogButtonBox.Yes|QDialogButtonBox.No
self.theButtonBox = QDialogButtonBox(
btnbox_style,
parent=self,
accepted=self.accept,
rejected=self.reject)
l.addWidget( self.theButtonBox )
def accept( self ):
self.was_accepted = True
QDialog.accept(self)
def reject( self ):
self.was_accepted = False
QDialog.reject(self)
@staticmethod
def msg( parent, title, msg, check_state=Qt.Unchecked, check_text=None):
d = OptionalMessageDialog( parent, StyleMessage, title, msg, check_state=check_state, check_text=check_text )
d.exec_()
return d.theCheckBox.isChecked()
@staticmethod
def question( parent, title, msg, check_state=Qt.Unchecked, check_text=None ):
d = OptionalMessageDialog( parent, StyleQuestion, title, msg, check_state=check_state, check_text=check_text )
d.exec_()
return d.was_accepted, d.theCheckBox.isChecked()

View File

@ -0,0 +1,390 @@
"""
CLI options class for comictagger app
"""
"""
Copyright 2012-2014 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 sys
import getopt
import platform
import os
import traceback
import ctversion
import utils
try:
import argparse
except:
pass
from genericmetadata import GenericMetadata
from comicarchive import MetaDataStyle
from versionchecker import VersionChecker
class Options:
help_text = """
Usage: {0} [OPTION]... [FILE LIST]
A utility for reading and writing metadata to comic archives.
If no options are given, {0} will run in windowed mode
-p, --print Print out tag info from file. Specify type
(via -t) to get only info of that tag type
--raw With -p, will print out the raw tag block(s)
from the file
-d, --delete Deletes the tag block of specified type (via -t)
-c, --copy=SOURCE Copy the specified source tag block to destination style
specified via via -t (potentially lossy operation)
-s, --save Save out tags as specified type (via -t)
Must specify also at least -o, -p, or -m
--nooverwrite Don't modify tag block if it already exists ( relevent for -s or -c )
-1, --assume-issue-one Assume issue number is 1 if not found ( relevent for -s )
-n, --dryrun Don't actually modify file (only relevent for -d, -s, or -r)
-t, --type=TYPE Specify TYPE as either "CR", "CBL", or "COMET" (as either
ComicRack, ComicBookLover, or CoMet style tags, respectivly)
-f, --parsefilename Parse the filename to get some info, specifically
series name, issue number, volume, and publication
year
-i, --interactive Interactively query the user when there are multiple matches for
an online search
--nosummary Suppress the default summary after a save operation
-o, --online Search online and attempt to identify file using
existing metadata and images in archive. May be used
in conjuntion with -f and -m
--id=ID Use the issue ID when searching online. Overrides all other metadata
-m, --metadata=LIST Explicity define, as a list, some tags to be used
e.g. "series=Plastic Man , publisher=Quality Comics"
"series=Kickers^, Inc., issue=1, year=1986"
Name-Value pairs are comma separated. Use a "^" to
escape an "=" or a ",", as shown in the example above
Some names that can be used:
series, issue, issueCount, year, publisher, title
-r, --rename Rename the file based on specified tag style.
--noabort Don't abort save operation when online match is of low confidence
-e, --export-to-zip Export RAR archive to Zip format
--delete-rar Delete original RAR archive after successful export to Zip
--abort-on-conflict Don't export to zip if intended new filename exists (Otherwise, creates
a new unique filename)
-S, --script=FILE Run an "add-on" python script that uses the comictagger library for custom
processing. Script arguments can follow the script name
-R, --recursive Recursively include files in sub-folders
--cv-api-key=KEY Use the given Comic Vine API Key (persisted in settings)
--only-set-cv-key Only set the Comiv Vine API key and quit
-w, --wait-on-cv-rate-limit When encountering a Comic Vine rate limit error, wait and retry query
-v, --verbose Be noisy when doing what it does
--terse Don't say much (for print mode)
--version Display version
-h, --help Display this message
For more help visit the wiki at: http://code.google.com/p/comictagger/
"""
def __init__(self):
self.data_style = None
self.no_gui = False
self.filename = None
self.verbose = False
self.terse = False
self.metadata = None
self.print_tags = False
self.copy_tags = False
self.delete_tags = False
self.export_to_zip = False
self.abort_export_on_conflict = False
self.delete_rar_after_export = False
self.search_online = False
self.dryrun = False
self.abortOnLowConfidence = True
self.save_tags = False
self.parse_filename = False
self.show_save_summary = False
self.raw = False
self.cv_api_key = None
self.only_set_key = False
self.rename_file = False
self.no_overwrite = False
self.interactive = False
self.issue_id = None
self.recursive = False
self.run_script = False
self.script = None
self.wait_and_retry_on_rate_limit = False
self.assume_issue_is_one_if_not_set = False
self.file_list = []
def display_msg_and_quit( self, msg, code, show_help=False ):
appname = os.path.basename(sys.argv[0])
if msg is not None:
print( msg )
if show_help:
print self.help_text.format(appname)
else:
print "For more help, run with '--help'"
sys.exit(code)
def parseMetadataFromString( self, mdstr ):
# The metadata string is a comma separated list of name-value pairs
# The names match the attributes of the internal metadata struct (for now)
# The caret is the special "escape character", since it's not common in
# natural language text
# example = "series=Kickers^, Inc. ,issue=1, year=1986"
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace( escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace( replacement_token, "," )
md_list.append(item)
# Now build a nice dict from the list
md_dict = dict()
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace( escaped_equals, replacement_token)
key,value = i.split("=")
value = value.replace( replacement_token, "=" ).strip()
key = key.strip()
if key.lower() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = ( cred_attribs[1] if len( cred_attribs ) > 1 else "" )
primary = (cred_attribs[2] if len( cred_attribs ) > 2 else None )
md.addCredit( person.strip(), role.strip(), True if primary is not None else False )
else:
md_dict[key] = value
# Map the dict to the metadata object
for key in md_dict:
if not hasattr(md, key):
print "Warning: '{0}' is not a valid tag name".format(key)
else:
md.isEmpty = False
setattr( md, key, md_dict[key] )
#print md
return md
def launch_script(self, scriptfile):
# we were given a script. special case for the args:
# 1. ignore everthing before the -S,
# 2. pass all the ones that follow (including script name) to the script
script_args = list()
for idx, arg in enumerate(sys.argv):
if arg in [ '-S', '--script']:
#found script!
script_args = sys.argv[idx+1:]
break
sys.argv = script_args
if not os.path.exists(scriptfile):
print "Can't find {0}".format( scriptfile )
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path
# import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
script = __import__(module_name)
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main()
else:
print "Can't find entry point \"main()\" in module \"{0}\"".format( module_name )
except Exception as e:
print "Script raised an unhandled exception: ", e
print traceback.format_exc()
sys.exit(0)
def parseCmdLineArgs(self):
if platform.system() == "Darwin" and hasattr(sys, "frozen") and sys.frozen == 1:
# remove the PSN ("process serial number") argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a ]
else:
input_args = sys.argv[1:]
# first check if we're launching a script:
for n in range(len(input_args)):
if ( input_args[n] in [ "-S", "--script" ] and
n+1 < len(input_args)):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
input_args.insert(n+2, "--")
break
# parse command line options
try:
opts, args = getopt.getopt( input_args,
"hpdt:fm:vownsrc:ieRS:1",
[ "help", "print", "delete", "type=", "copy=", "parsefilename", "metadata=", "verbose",
"online", "dryrun", "save", "rename" , "raw", "noabort", "terse", "nooverwrite",
"interactive", "nosummary", "version", "id=" , "recursive", "script=",
"export-to-zip", "delete-rar", "abort-on-conflict", "assume-issue-one",
"cv-api-key=", "only-set-cv-key", "wait-on-cv-rate-limit" ] )
except getopt.GetoptError as err:
self.display_msg_and_quit( str(err), 2 )
# process options
for o, a in opts:
if o in ("-h", "--help"):
self.display_msg_and_quit( None, 0, show_help=True )
if o in ("-v", "--verbose"):
self.verbose = True
if o in ("-S", "--script"):
self.run_script = True
self.script = a
if o in ("-R", "--recursive"):
self.recursive = True
if o in ("-p", "--print"):
self.print_tags = True
if o in ("-d", "--delete"):
self.delete_tags = True
if o in ("-i", "--interactive"):
self.interactive = True
if o in ("-c", "--copy"):
self.copy_tags = True
if a.lower() == "cr":
self.copy_source = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.copy_source = MetaDataStyle.CBI
elif a.lower() == "comet":
self.copy_source = MetaDataStyle.COMET
else:
self.display_msg_and_quit( "Invalid copy tag source type", 1 )
if o in ("-o", "--online"):
self.search_online = True
if o in ("-n", "--dryrun"):
self.dryrun = True
if o in ("-m", "--metadata"):
self.metadata = self.parseMetadataFromString(a)
if o in ("-s", "--save"):
self.save_tags = True
if o in ("-r", "--rename"):
self.rename_file = True
if o in ("-e", "--export_to_zip"):
self.export_to_zip = True
if o == "--delete-rar":
self.delete_rar_after_export = True
if o == "--abort-on-conflict":
self.abort_export_on_conflict = True
if o in ("-f", "--parsefilename"):
self.parse_filename = True
if o in ("-w", "--wait-on-cv-rate-limit"):
self.wait_and_retry_on_rate_limit = True
if o == "--id":
self.issue_id = a
if o == "--raw":
self.raw = True
if o == "--noabort":
self.abortOnLowConfidence = False
if o == "--terse":
self.terse = True
if o == "--nosummary":
self.show_save_summary = False
if o in ("-1", "--assume-issue-one"):
self.assume_issue_is_one_if_not_set = True
if o == "--nooverwrite":
self.no_overwrite = True
if o == "--cv-api-key":
self.cv_api_key = a
if o == "--only-set-cv-key":
self.only_set_key = True
if o == "--version":
print "ComicTagger {0}: Copyright (c) 2012-2014 Anthony Beville".format(ctversion.version)
print "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)"
new_version = VersionChecker().getLatestVersion("", False)
if new_version is not None and new_version != ctversion.version:
print "----------------------------------------"
print "New version available online: {0}".format(new_version)
print "----------------------------------------"
sys.exit(0)
if o in ("-t", "--type"):
if a.lower() == "cr":
self.data_style = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.data_style = MetaDataStyle.CBI
elif a.lower() == "comet":
self.data_style = MetaDataStyle.COMET
else:
self.display_msg_and_quit( "Invalid tag type", 1 )
if self.print_tags or self.delete_tags or self.save_tags or self.copy_tags or self.rename_file or self.export_to_zip or self.only_set_key:
self.no_gui = True
count = 0
if self.run_script: count += 1
if self.print_tags: count += 1
if self.delete_tags: count += 1
if self.save_tags: count += 1
if self.copy_tags: count += 1
if self.rename_file: count += 1
if self.export_to_zip: count +=1
if self.only_set_key: count +=1
if count > 1:
self.display_msg_and_quit( "Must choose only one action of print, delete, save, copy, rename, export, set key, or run script", 1 )
if self.script is not None:
self.launch_script( self.script )
if len(args) > 0:
if platform.system() == "Windows":
# no globbing on windows shell, so do it for them
import glob
self.file_list = []
for item in args:
self.file_list.extend(glob.glob(item))
if len(self.file_list) > 0:
self.filename = self.file_list[0]
else:
self.filename = args[0]
self.file_list = args
if self.only_set_key and self.cv_api_key == None:
self.display_msg_and_quit( "Key not given!", 1 )
if (self.only_set_key == False) and self.no_gui and (self.filename is None):
self.display_msg_and_quit( "Command requires at least one filename!", 1 )
if self.delete_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to delete with -t", 1 )
if self.save_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to save with -t", 1 )
if self.copy_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to copy to with -t", 1 )
#if self.rename_file and self.data_style is None:
# self.display_msg_and_quit( "Please specify the type to use for renaming with -t", 1 )
if self.recursive:
self.file_list = utils.get_recursive_filelist( self.file_list )

View File

@ -0,0 +1,110 @@
"""
A PyQT4 dialog to show pages of a comic archive
"""
"""
Copyright 2012-2014 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 platform
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
from coverimagewidget import CoverImageWidget
class PageBrowserWindow(QtGui.QDialog):
def __init__(self, parent, metadata):
super(PageBrowserWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('pagebrowser.ui' ), self)
self.pageWidget = CoverImageWidget( self.pageContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.pageContainer )
gridlayout.addWidget( self.pageWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.pageWidget.showControls = False
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = metadata
self.buttonBox.button(QtGui.QDialogButtonBox.Close).setDefault(True)
if platform.system() == "Darwin":
self.btnPrev.setText("<<")
self.btnNext.setText(">>")
else:
self.btnPrev.setIcon(QtGui.QIcon( ComicTaggerSettings.getGraphic('left.png' )))
self.btnNext.setIcon(QtGui.QIcon( ComicTaggerSettings.getGraphic('right.png')))
self.btnNext.clicked.connect( self.nextPage )
self.btnPrev.clicked.connect( self.prevPage )
self.show()
self.btnNext.setEnabled( False )
self.btnPrev.setEnabled( False )
def reset( self ):
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = None
self.btnNext.setEnabled( False )
self.btnPrev.setEnabled( False )
self.pageWidget.clear()
def setComicArchive(self, ca):
self.comic_archive = ca
self.page_count = ca.getNumberOfPages()
self.current_page_num = 0
self.pageWidget.setArchive( self.comic_archive )
self.setPage()
if self.page_count > 1:
self.btnNext.setEnabled( True )
self.btnPrev.setEnabled( True )
def nextPage(self):
if self.current_page_num + 1 < self.page_count:
self.current_page_num += 1
else:
self.current_page_num = 0
self.setPage()
def prevPage(self):
if self.current_page_num - 1 >= 0:
self.current_page_num -= 1
else:
self.current_page_num = self.page_count - 1
self.setPage()
def setPage( self ):
if self.metadata is not None:
archive_page_index = self.metadata.getArchivePageIndex( self.current_page_num )
else:
archive_page_index = self.current_page_num
self.pageWidget.setPage( archive_page_index )
self.setWindowTitle("Page Browser - Page {0} (of {1}) ".format(self.current_page_num+1, self.page_count ) )

View File

@ -0,0 +1,273 @@
"""
A PyQt4 widget for editing the page list info
"""
"""
Copyright 2012-2014 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 os
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from settings import ComicTaggerSettings
from genericmetadata import GenericMetadata, PageType
from comicarchive import MetaDataStyle
from pageloader import PageLoader
from coverimagewidget import CoverImageWidget
def itemMoveEvents( widget ):
class Filter(QObject):
mysignal = pyqtSignal( str )
def eventFilter(self, obj, event):
if obj == widget:
#print event.type()
if event.type() == QEvent.ChildRemoved:
#print "ChildRemoved"
self.mysignal.emit("finish")
if event.type() == QEvent.ChildAdded:
#print "ChildAdded"
self.mysignal.emit("start")
return True
return False
filter = Filter( widget )
widget.installEventFilter( filter )
return filter.mysignal
class PageListEditor(QWidget):
firstFrontCoverChanged = pyqtSignal( int )
listOrderChanged = pyqtSignal( )
modified = pyqtSignal( )
pageTypeNames = {
PageType.FrontCover: "Front Cover",
PageType.InnerCover: "Inner Cover",
PageType.Advertisement: "Advertisement",
PageType.Roundup: "Roundup",
PageType.Story: "Story",
PageType.Editorial: "Editorial",
PageType.Letters: "Letters",
PageType.Preview: "Preview",
PageType.BackCover: "Back Cover",
PageType.Other: "Other",
PageType.Deleted: "Deleted",
}
def __init__(self, parent ):
super(PageListEditor, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('pagelisteditor.ui' ), self)
self.pageWidget = CoverImageWidget( self.pageContainer, CoverImageWidget.ArchiveMode )
gridlayout = QGridLayout( self.pageContainer )
gridlayout.addWidget( self.pageWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.pageWidget.showControls = False
self.resetPage()
# Add the entries to the manga combobox
self.comboBox.addItem( "", "" )
self.comboBox.addItem( self.pageTypeNames[ PageType.FrontCover], PageType.FrontCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.InnerCover], PageType.InnerCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.Advertisement], PageType.Advertisement )
self.comboBox.addItem( self.pageTypeNames[ PageType.Roundup], PageType.Roundup )
self.comboBox.addItem( self.pageTypeNames[ PageType.Story], PageType.Story )
self.comboBox.addItem( self.pageTypeNames[ PageType.Editorial], PageType.Editorial )
self.comboBox.addItem( self.pageTypeNames[ PageType.Letters], PageType.Letters )
self.comboBox.addItem( self.pageTypeNames[ PageType.Preview], PageType.Preview )
self.comboBox.addItem( self.pageTypeNames[ PageType.BackCover], PageType.BackCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.Other], PageType.Other )
self.comboBox.addItem( self.pageTypeNames[ PageType.Deleted], PageType.Deleted )
self.listWidget.itemSelectionChanged.connect( self.changePage )
itemMoveEvents(self.listWidget).connect(self.itemMoveEvent)
self.comboBox.activated.connect( self.changePageType )
self.btnUp.clicked.connect( self.moveCurrentUp )
self.btnDown.clicked.connect( self.moveCurrentDown )
self.pre_move_row = -1
self.first_front_page = None
def resetPage( self ):
self.pageWidget.clear()
self.comboBox.setDisabled(True)
self.comic_archive = None
self.pages_list = None
def moveCurrentUp( self ):
row = self.listWidget.currentRow()
if row > 0:
item = self.listWidget.takeItem ( row )
self.listWidget.insertItem( row-1, item )
self.listWidget.setCurrentRow( row-1 )
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def moveCurrentDown( self ):
row = self.listWidget.currentRow()
if row < self.listWidget.count()-1:
item = self.listWidget.takeItem ( row )
self.listWidget.insertItem( row+1, item )
self.listWidget.setCurrentRow( row+1 )
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def itemMoveEvent(self, s):
#print "move event: ", s, self.listWidget.currentRow()
if s == "start":
self.pre_move_row = self.listWidget.currentRow()
if s == "finish":
if self.pre_move_row != self.listWidget.currentRow():
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def changePageType( self , i):
new_type = self.comboBox.itemData(i).toString()
if self.getCurrentPageType() != new_type:
self.setCurrentPageType( new_type )
self.emitFrontCoverChange()
self.modified.emit()
def changePage( self ):
row = self.listWidget.currentRow()
pagetype = self.getCurrentPageType()
i = self.comboBox.findData( pagetype )
self.comboBox.setCurrentIndex( i )
#idx = int(str (self.listWidget.item( row ).text()))
idx = int(self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]['Image'])
if self.comic_archive is not None:
self.pageWidget.setArchive( self.comic_archive, idx )
def getFirstFrontCover( self ):
frontCover = 0
for i in range( self.listWidget.count() ):
item = self.listWidget.item( i )
page_dict = item.data(Qt.UserRole).toPyObject()[0]
if 'Type' in page_dict and page_dict['Type'] == PageType.FrontCover:
frontCover = int(page_dict['Image'])
break
return frontCover
def getCurrentPageType( self ):
row = self.listWidget.currentRow()
page_dict = self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]
if 'Type' in page_dict:
return page_dict['Type']
else:
return ""
def setCurrentPageType( self, t ):
row = self.listWidget.currentRow()
page_dict = self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]
if t == "":
if 'Type' in page_dict:
del(page_dict['Type'])
else:
page_dict['Type'] = str(t)
item = self.listWidget.item( row )
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(Qt.UserRole, (page_dict,) )
item.setText( self.listEntryText( page_dict ) )
def setData( self, comic_archive, pages_list ):
self.comic_archive = comic_archive
self.pages_list = pages_list
if pages_list is not None and len(pages_list) > 0:
self.comboBox.setDisabled(False)
self.listWidget.itemSelectionChanged.disconnect( self.changePage )
self.listWidget.clear()
for p in pages_list:
item = QListWidgetItem( self.listEntryText( p ) )
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(Qt.UserRole, (p, ))
self.listWidget.addItem( item )
self.first_front_page = self.getFirstFrontCover()
self.listWidget.itemSelectionChanged.connect( self.changePage )
self.listWidget.setCurrentRow ( 0 )
def listEntryText(self, page_dict):
text = str(int(page_dict['Image']) + 1)
if 'Type' in page_dict:
text += " (" + self.pageTypeNames[page_dict['Type']] + ")"
return text
def getPageList( self ):
page_list = []
for i in range( self.listWidget.count() ):
item = self.listWidget.item( i )
page_list.append( item.data(Qt.UserRole).toPyObject()[0] )
return page_list
def emitFrontCoverChange( self ):
if self.first_front_page != self.getFirstFrontCover():
self.first_front_page = self.getFirstFrontCover()
self.firstFrontCoverChanged.emit( self.first_front_page )
def setMetadataStyle( self, data_style ):
# depending on the current data style, certain fields are disabled
inactive_color = QColor(255, 170, 150)
active_palette = self.comboBox.palette()
inactive_palette3 = self.comboBox.palette()
inactive_palette3.setColor(QPalette.Base, inactive_color)
if data_style == MetaDataStyle.CIX:
self.btnUp.setEnabled( True )
self.btnDown.setEnabled( True )
self.comboBox.setEnabled( True )
self.listWidget.setEnabled( True )
self.listWidget.setPalette(active_palette)
elif data_style == MetaDataStyle.CBI:
self.btnUp.setEnabled( False )
self.btnDown.setEnabled( False )
self.comboBox.setEnabled( False )
self.listWidget.setEnabled( False )
self.listWidget.setPalette(inactive_palette3)
elif data_style == MetaDataStyle.CoMet:
pass
# make sure combo is disabled when no list
if self.comic_archive is None:
self.comboBox.setEnabled( False )

View File

@ -0,0 +1,77 @@
"""
A PyQT4 class to load a page image from a ComicArchive in a background thread
"""
"""
Copyright 2012-2014 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.
"""
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import pyqtSignal
from comicarchive import ComicArchive
import utils
"""
This class holds onto a reference of each instance in a list
since problems occur if the ref count goes to zero and the GC
tries to reap the object while the thread is going.
If the client class wants to stop the thread, they should mark
it as "abandoned", and no signals will be issued
"""
class PageLoader( QtCore.QThread ):
loadComplete = pyqtSignal( QtGui.QImage )
instanceList = []
mutex = QtCore.QMutex()
"""
Remove all finished threads from the list
"""
@staticmethod
def reapInstances():
for obj in reversed(PageLoader.instanceList ):
if obj.isFinished():
PageLoader.instanceList.remove(obj)
def __init__(self, ca, page_num ):
QtCore.QThread.__init__(self)
self.ca = ca
self.page_num = page_num
self.abandoned = False
# remove any old instances, and then add ourself
PageLoader.mutex.lock()
PageLoader.reapInstances()
PageLoader.instanceList.append( self )
PageLoader.mutex.unlock()
def run(self):
image_data = self.ca.getPage( self.page_num )
if self.abandoned:
return
if image_data is not None:
img = utils.getQImageFromData( image_data)
if self.abandoned:
return
self.loadComplete.emit( img )

View File

@ -0,0 +1,42 @@
"""
A PyQT4 dialog to show ID log and progress
"""
"""
Copyright 2012-2014 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 sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
import utils
class IDProgressWindow(QtGui.QDialog):
def __init__(self, parent):
super(IDProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('progresswindow.ui' ), self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
utils.reduceWidgetFontSize( self.textEdit )

View File

@ -0,0 +1,157 @@
"""
A PyQT4 dialog to confirm rename
"""
"""
Copyright 2012-2014 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.
"""
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from settingswindow import SettingsWindow
from filerenamer import FileRenamer
from comicarchive import MetaDataStyle
import os
import utils
class RenameWindow(QtGui.QDialog):
def __init__( self, parent, comic_archive_list, data_style, settings ):
super(RenameWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('renamewindow.ui' ), self)
self.label.setText("Preview (based on {0} tags):".format(MetaDataStyle.name[data_style]))
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.settings = settings
self.comic_archive_list = comic_archive_list
self.data_style = data_style
self.btnSettings.clicked.connect( self.modifySettings )
self.configRenamer()
self.doPreview()
def configRenamer( self ):
self.renamer = FileRenamer( None )
self.renamer.setTemplate( self.settings.rename_template )
self.renamer.setIssueZeroPadding( self.settings.rename_issue_number_padding )
self.renamer.setSmartCleanup( self.settings.rename_use_smart_string_cleanup )
def doPreview( self ):
self.rename_list = []
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
for ca in self.comic_archive_list:
new_ext = None # default
if self.settings.rename_extension_based_on_archive:
if ca.isZip():
new_ext = ".cbz"
elif ca.isRar():
new_ext = ".cbr"
md = ca.readMetadata(self.data_style)
if md.isEmpty:
md = ca.metadataFromFilename(self.settings.parse_scan_info)
self.renamer.setMetadata( md )
new_name = self.renamer.determineName( ca.path, ext=new_ext )
row = self.twList.rowCount()
self.twList.insertRow( row )
folder_item = QtGui.QTableWidgetItem()
old_name_item = QtGui.QTableWidgetItem()
new_name_item = QtGui.QTableWidgetItem()
item_text = os.path.split(ca.path)[0]
folder_item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, folder_item)
folder_item.setText( item_text )
folder_item.setData( QtCore.Qt.ToolTipRole, item_text )
item_text = os.path.split(ca.path)[1]
old_name_item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, old_name_item)
old_name_item.setText( item_text )
old_name_item.setData( QtCore.Qt.ToolTipRole, item_text )
new_name_item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, new_name_item)
new_name_item.setText( new_name )
new_name_item.setData( QtCore.Qt.ToolTipRole, new_name )
dict_item = dict()
dict_item['archive'] = ca
dict_item['new_name'] = new_name
self.rename_list.append( dict_item)
# Adjust column sizes
self.twList.setVisible( False )
self.twList.resizeColumnsToContents()
self.twList.setVisible( True )
if self.twList.columnWidth(0) > 200:
self.twList.setColumnWidth(0, 200)
self.twList.setSortingEnabled(True)
def modifySettings( self ):
settingswin = SettingsWindow( self, self.settings )
settingswin.setModal(True)
settingswin.showRenameTab()
settingswin.exec_()
if settingswin.result():
self.configRenamer()
self.doPreview()
def accept( self ):
progdialog = QtGui.QProgressDialog("", "Cancel", 0, len(self.rename_list), self)
progdialog.setWindowTitle( "Renaming Archives" )
progdialog.setWindowModality(QtCore.Qt.WindowModal)
progdialog.show()
for idx,item in enumerate(self.rename_list):
QtCore.QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx)
idx += 1
progdialog.setLabelText( item['new_name'] )
if item['new_name'] == os.path.basename( item['archive'].path ):
print item['new_name'] , "Filename is already good!"
continue
if not item['archive'].isWritable(check_rar_status=False):
continue
folder = os.path.dirname( os.path.abspath( item['archive'].path ) )
new_abs_path = utils.unique_file( os.path.join( folder, item['new_name'] ) )
os.rename( item['archive'].path, new_abs_path)
item['archive'].rename( new_abs_path )
progdialog.close()
QtGui.QDialog.accept(self)

View File

@ -0,0 +1,389 @@
"""
Settings class for comictagger app
"""
"""
Copyright 2012-2014 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 sys
import os
import sys
import configparser
import platform
import codecs
import uuid
import utils
class ComicTaggerSettings:
@staticmethod
def getSettingsFolder():
filename_encoding = sys.getfilesystemencoding()
if platform.system() == "Windows":
folder = os.path.join( os.environ['APPDATA'], 'ComicTagger' )
else:
folder = os.path.join( os.path.expanduser('~') , '.ComicTagger')
if folder is not None:
folder = folder.decode(filename_encoding)
return folder
frozen_win_exe_path = None
@staticmethod
def baseDir():
if getattr(sys, 'frozen', None):
if platform.system() == "Darwin":
return sys._MEIPASS
else: # Windows
#Preserve this value, in case sys.argv gets changed importing a plugin script
if ComicTaggerSettings.frozen_win_exe_path is None:
ComicTaggerSettings.frozen_win_exe_path = os.path.dirname( os.path.abspath( sys.argv[0] ) )
return ComicTaggerSettings.frozen_win_exe_path
else:
return os.path.dirname( os.path.abspath( __file__) )
@staticmethod
def getGraphic( filename ):
graphic_folder = os.path.join(ComicTaggerSettings.baseDir(), 'graphics')
return os.path.join( graphic_folder, filename )
@staticmethod
def getUIFile( filename ):
ui_folder = os.path.join(ComicTaggerSettings.baseDir(), 'ui')
return os.path.join( ui_folder, filename )
def setDefaultValues( self ):
# General Settings
self.rar_exe_path = ""
self.unrar_exe_path = ""
self.allow_cbi_in_rar = True
self.check_for_new_version = True
self.send_usage_stats = False
# automatic settings
self.install_id = uuid.uuid4().hex
self.last_selected_save_data_style = 0
self.last_selected_load_data_style = 0
self.last_opened_folder = ""
self.last_main_window_width = 0
self.last_main_window_height = 0
self.last_main_window_x = 0
self.last_main_window_y = 0
self.last_form_side_width = -1
self.last_list_side_width = -1
self.last_filelist_sorted_column = -1
self.last_filelist_sorted_order = 0
# identifier settings
self.id_length_delta_thresh = 5
self.id_publisher_blacklist = "Panini Comics, Abril, Planeta DeAgostini, Editorial Televisa"
# Show/ask dialog flags
self.ask_about_cbi_in_rar = True
self.show_disclaimer = True
self.dont_notify_about_this_version = ""
self.ask_about_usage_stats = True
self.show_no_unrar_warning = True
#filename parsing settings
self.parse_scan_info = True
# Comic Vine settings
self.use_series_start_as_volume = False
self.clear_form_before_populating_from_cv = False
self.remove_html_tables = False
self.cv_api_key = ""
# CBL Tranform settings
self.assume_lone_credit_is_primary = False
self.copy_characters_to_tags = False
self.copy_teams_to_tags = False
self.copy_locations_to_tags = False
self.copy_storyarcs_to_tags = False
self.copy_notes_to_comments = False
self.copy_weblink_to_comments = False
self.apply_cbl_transform_on_cv_import = False
self.apply_cbl_transform_on_bulk_operation = False
# Rename settings
self.rename_template = "%series% #%issue% (%year%)"
self.rename_issue_number_padding = 3
self.rename_use_smart_string_cleanup = True
self.rename_extension_based_on_archive = True
#Auto-tag stickies
self.save_on_low_confidence = False
self.dont_use_year_when_identifying = False
self.assume_1_if_no_issue_num = False
self.ignore_leading_numbers_in_filename = False
self.remove_archive_after_successful_match = False
self.wait_and_retry_on_rate_limit = False
def __init__(self):
self.settings_file = ""
self.folder = ""
self.setDefaultValues()
self.config = configparser.RawConfigParser()
self.folder = ComicTaggerSettings.getSettingsFolder()
if not os.path.exists( self.folder ):
os.makedirs( self.folder )
self.settings_file = os.path.join( self.folder, "settings")
# if config file doesn't exist, write one out
if not os.path.exists( self.settings_file ):
self.save()
else:
self.load()
# take a crack at finding rar exes, if not set already
if self.rar_exe_path == "":
if platform.system() == "Windows":
# look in some likely places for windows machine
if os.path.exists( "C:\Program Files\WinRAR\Rar.exe" ):
self.rar_exe_path = "C:\Program Files\WinRAR\Rar.exe"
elif os.path.exists( "C:\Program Files (x86)\WinRAR\Rar.exe" ):
self.rar_exe_path = "C:\Program Files (x86)\WinRAR\Rar.exe"
else:
# see if it's in the path of unix user
if utils.which("rar") is not None:
self.rar_exe_path = utils.which("rar")
if self.rar_exe_path != "":
self.save()
if self.unrar_exe_path == "":
if platform.system() != "Windows":
# see if it's in the path of unix user
if utils.which("unrar") is not None:
self.unrar_exe_path = utils.which("unrar")
if self.unrar_exe_path != "":
self.save()
# make sure unrar/rar program is now in the path for the UnRAR class to use
utils.addtopath(os.path.dirname(self.unrar_exe_path))
utils.addtopath(os.path.dirname(self.rar_exe_path))
def reset( self ):
os.unlink( self.settings_file )
self.__init__()
def load(self):
def readline_generator(f):
line = f.readline()
while line:
yield line
line = f.readline()
#self.config.readfp(codecs.open(self.settings_file, "r", "utf8"))
self.config.read_file(readline_generator(codecs.open(self.settings_file, "r", "utf8")))
self.rar_exe_path = self.config.get( 'settings', 'rar_exe_path' )
self.unrar_exe_path = self.config.get( 'settings', 'unrar_exe_path' )
if self.config.has_option('settings', 'check_for_new_version'):
self.check_for_new_version = self.config.getboolean( 'settings', 'check_for_new_version' )
if self.config.has_option('settings', 'send_usage_stats'):
self.send_usage_stats = self.config.getboolean( 'settings', 'send_usage_stats' )
if self.config.has_option('auto', 'install_id'):
self.install_id = self.config.get( 'auto', 'install_id' )
if self.config.has_option('auto', 'last_selected_load_data_style'):
self.last_selected_load_data_style = self.config.getint( 'auto', 'last_selected_load_data_style' )
if self.config.has_option('auto', 'last_selected_save_data_style'):
self.last_selected_save_data_style = self.config.getint( 'auto', 'last_selected_save_data_style' )
if self.config.has_option('auto', 'last_opened_folder'):
self.last_opened_folder = self.config.get( 'auto', 'last_opened_folder' )
if self.config.has_option('auto', 'last_main_window_width'):
self.last_main_window_width = self.config.getint( 'auto', 'last_main_window_width' )
if self.config.has_option('auto', 'last_main_window_height'):
self.last_main_window_height = self.config.getint( 'auto', 'last_main_window_height' )
if self.config.has_option('auto', 'last_main_window_x'):
self.last_main_window_x = self.config.getint( 'auto', 'last_main_window_x' )
if self.config.has_option('auto', 'last_main_window_y'):
self.last_main_window_y = self.config.getint( 'auto', 'last_main_window_y' )
if self.config.has_option('auto', 'last_form_side_width'):
self.last_form_side_width = self.config.getint( 'auto', 'last_form_side_width' )
if self.config.has_option('auto', 'last_list_side_width'):
self.last_list_side_width = self.config.getint( 'auto', 'last_list_side_width' )
if self.config.has_option('auto', 'last_filelist_sorted_column'):
self.last_filelist_sorted_column = self.config.getint( 'auto', 'last_filelist_sorted_column' )
if self.config.has_option('auto', 'last_filelist_sorted_order'):
self.last_filelist_sorted_order = self.config.getint( 'auto', 'last_filelist_sorted_order' )
if self.config.has_option('identifier', 'id_length_delta_thresh'):
self.id_length_delta_thresh = self.config.getint( 'identifier', 'id_length_delta_thresh' )
if self.config.has_option('identifier', 'id_publisher_blacklist'):
self.id_publisher_blacklist = self.config.get( 'identifier', 'id_publisher_blacklist' )
if self.config.has_option('filenameparser', 'parse_scan_info'):
self.parse_scan_info = self.config.getboolean( 'filenameparser', 'parse_scan_info' )
if self.config.has_option('dialogflags', 'ask_about_cbi_in_rar'):
self.ask_about_cbi_in_rar = self.config.getboolean( 'dialogflags', 'ask_about_cbi_in_rar' )
if self.config.has_option('dialogflags', 'show_disclaimer'):
self.show_disclaimer = self.config.getboolean( 'dialogflags', 'show_disclaimer' )
if self.config.has_option('dialogflags', 'dont_notify_about_this_version'):
self.dont_notify_about_this_version = self.config.get( 'dialogflags', 'dont_notify_about_this_version' )
if self.config.has_option('dialogflags', 'ask_about_usage_stats'):
self.ask_about_usage_stats = self.config.getboolean( 'dialogflags', 'ask_about_usage_stats' )
if self.config.has_option('dialogflags', 'show_no_unrar_warning'):
self.show_no_unrar_warning = self.config.getboolean( 'dialogflags', 'show_no_unrar_warning' )
if self.config.has_option('comicvine', 'use_series_start_as_volume'):
self.use_series_start_as_volume = self.config.getboolean( 'comicvine', 'use_series_start_as_volume' )
if self.config.has_option('comicvine', 'clear_form_before_populating_from_cv'):
self.clear_form_before_populating_from_cv = self.config.getboolean( 'comicvine', 'clear_form_before_populating_from_cv' )
if self.config.has_option('comicvine', 'remove_html_tables'):
self.remove_html_tables = self.config.getboolean( 'comicvine', 'remove_html_tables' )
if self.config.has_option('comicvine', 'cv_api_key'):
self.cv_api_key = self.config.get( 'comicvine', 'cv_api_key' )
if self.config.has_option('cbl_transform', 'assume_lone_credit_is_primary'):
self.assume_lone_credit_is_primary = self.config.getboolean( 'cbl_transform', 'assume_lone_credit_is_primary' )
if self.config.has_option('cbl_transform', 'copy_characters_to_tags'):
self.copy_characters_to_tags = self.config.getboolean( 'cbl_transform', 'copy_characters_to_tags' )
if self.config.has_option('cbl_transform', 'copy_teams_to_tags'):
self.copy_teams_to_tags = self.config.getboolean( 'cbl_transform', 'copy_teams_to_tags' )
if self.config.has_option('cbl_transform', 'copy_locations_to_tags'):
self.copy_locations_to_tags = self.config.getboolean( 'cbl_transform', 'copy_locations_to_tags' )
if self.config.has_option('cbl_transform', 'copy_notes_to_comments'):
self.copy_notes_to_comments = self.config.getboolean( 'cbl_transform', 'copy_notes_to_comments' )
if self.config.has_option('cbl_transform', 'copy_storyarcs_to_tags'):
self.copy_storyarcs_to_tags = self.config.getboolean( 'cbl_transform', 'copy_storyarcs_to_tags' )
if self.config.has_option('cbl_transform', 'copy_weblink_to_comments'):
self.copy_weblink_to_comments = self.config.getboolean( 'cbl_transform', 'copy_weblink_to_comments' )
if self.config.has_option('cbl_transform', 'apply_cbl_transform_on_cv_import'):
self.apply_cbl_transform_on_cv_import = self.config.getboolean( 'cbl_transform', 'apply_cbl_transform_on_cv_import' )
if self.config.has_option('cbl_transform', 'apply_cbl_transform_on_bulk_operation'):
self.apply_cbl_transform_on_bulk_operation = self.config.getboolean( 'cbl_transform', 'apply_cbl_transform_on_bulk_operation' )
if self.config.has_option('rename', 'rename_template'):
self.rename_template = self.config.get( 'rename', 'rename_template' )
if self.config.has_option('rename', 'rename_issue_number_padding'):
self.rename_issue_number_padding = self.config.getint( 'rename', 'rename_issue_number_padding' )
if self.config.has_option('rename', 'rename_use_smart_string_cleanup'):
self.rename_use_smart_string_cleanup = self.config.getboolean( 'rename', 'rename_use_smart_string_cleanup' )
if self.config.has_option('rename', 'rename_extension_based_on_archive'):
self.rename_extension_based_on_archive = self.config.getboolean( 'rename', 'rename_extension_based_on_archive' )
if self.config.has_option('autotag', 'save_on_low_confidence'):
self.save_on_low_confidence = self.config.getboolean( 'autotag', 'save_on_low_confidence' )
if self.config.has_option('autotag', 'dont_use_year_when_identifying'):
self.dont_use_year_when_identifying = self.config.getboolean( 'autotag', 'dont_use_year_when_identifying' )
if self.config.has_option('autotag', 'assume_1_if_no_issue_num'):
self.assume_1_if_no_issue_num = self.config.getboolean( 'autotag', 'assume_1_if_no_issue_num' )
if self.config.has_option('autotag', 'ignore_leading_numbers_in_filename'):
self.ignore_leading_numbers_in_filename = self.config.getboolean( 'autotag', 'ignore_leading_numbers_in_filename' )
if self.config.has_option('autotag', 'remove_archive_after_successful_match'):
self.remove_archive_after_successful_match = self.config.getboolean( 'autotag', 'remove_archive_after_successful_match' )
if self.config.has_option('autotag', 'wait_and_retry_on_rate_limit'):
self.wait_and_retry_on_rate_limit = self.config.getboolean( 'autotag', 'wait_and_retry_on_rate_limit' )
def save( self ):
if not self.config.has_section( 'settings' ):
self.config.add_section( 'settings' )
self.config.set( 'settings', 'check_for_new_version', self.check_for_new_version )
self.config.set( 'settings', 'rar_exe_path', self.rar_exe_path )
self.config.set( 'settings', 'unrar_exe_path', self.unrar_exe_path )
self.config.set( 'settings', 'send_usage_stats', self.send_usage_stats )
if not self.config.has_section( 'auto' ):
self.config.add_section( 'auto' )
self.config.set( 'auto', 'install_id', self.install_id )
self.config.set( 'auto', 'last_selected_load_data_style', self.last_selected_load_data_style )
self.config.set( 'auto', 'last_selected_save_data_style', self.last_selected_save_data_style )
self.config.set( 'auto', 'last_opened_folder', self.last_opened_folder )
self.config.set( 'auto', 'last_main_window_width', self.last_main_window_width )
self.config.set( 'auto', 'last_main_window_height', self.last_main_window_height )
self.config.set( 'auto', 'last_main_window_x', self.last_main_window_x )
self.config.set( 'auto', 'last_main_window_y', self.last_main_window_y )
self.config.set( 'auto', 'last_form_side_width', self.last_form_side_width )
self.config.set( 'auto', 'last_list_side_width', self.last_list_side_width )
self.config.set( 'auto', 'last_filelist_sorted_column', self.last_filelist_sorted_column )
self.config.set( 'auto', 'last_filelist_sorted_order', self.last_filelist_sorted_order )
if not self.config.has_section( 'identifier' ):
self.config.add_section( 'identifier' )
self.config.set( 'identifier', 'id_length_delta_thresh', self.id_length_delta_thresh )
self.config.set( 'identifier', 'id_publisher_blacklist', self.id_publisher_blacklist )
if not self.config.has_section( 'dialogflags' ):
self.config.add_section( 'dialogflags' )
self.config.set( 'dialogflags', 'ask_about_cbi_in_rar', self.ask_about_cbi_in_rar )
self.config.set( 'dialogflags', 'show_disclaimer', self.show_disclaimer )
self.config.set( 'dialogflags', 'dont_notify_about_this_version', self.dont_notify_about_this_version )
self.config.set( 'dialogflags', 'ask_about_usage_stats', self.ask_about_usage_stats )
self.config.set( 'dialogflags', 'show_no_unrar_warning', self.show_no_unrar_warning )
if not self.config.has_section( 'filenameparser' ):
self.config.add_section( 'filenameparser' )
self.config.set( 'filenameparser', 'parse_scan_info', self.parse_scan_info )
if not self.config.has_section( 'comicvine' ):
self.config.add_section( 'comicvine' )
self.config.set( 'comicvine', 'use_series_start_as_volume', self.use_series_start_as_volume )
self.config.set( 'comicvine', 'clear_form_before_populating_from_cv', self.clear_form_before_populating_from_cv )
self.config.set( 'comicvine', 'remove_html_tables', self.remove_html_tables )
self.config.set( 'comicvine', 'cv_api_key', self.cv_api_key )
if not self.config.has_section( 'cbl_transform' ):
self.config.add_section( 'cbl_transform' )
self.config.set( 'cbl_transform', 'assume_lone_credit_is_primary', self.assume_lone_credit_is_primary )
self.config.set( 'cbl_transform', 'copy_characters_to_tags', self.copy_characters_to_tags )
self.config.set( 'cbl_transform', 'copy_teams_to_tags', self.copy_teams_to_tags )
self.config.set( 'cbl_transform', 'copy_locations_to_tags', self.copy_locations_to_tags )
self.config.set( 'cbl_transform', 'copy_storyarcs_to_tags', self.copy_storyarcs_to_tags )
self.config.set( 'cbl_transform', 'copy_notes_to_comments', self.copy_notes_to_comments )
self.config.set( 'cbl_transform', 'copy_weblink_to_comments', self.copy_weblink_to_comments )
self.config.set( 'cbl_transform', 'apply_cbl_transform_on_cv_import', self.apply_cbl_transform_on_cv_import )
self.config.set( 'cbl_transform', 'apply_cbl_transform_on_bulk_operation', self.apply_cbl_transform_on_bulk_operation )
if not self.config.has_section( 'rename' ):
self.config.add_section( 'rename' )
self.config.set( 'rename', 'rename_template', self.rename_template )
self.config.set( 'rename', 'rename_issue_number_padding', self.rename_issue_number_padding )
self.config.set( 'rename', 'rename_use_smart_string_cleanup', self.rename_use_smart_string_cleanup )
self.config.set( 'rename', 'rename_extension_based_on_archive', self.rename_extension_based_on_archive )
if not self.config.has_section( 'autotag' ):
self.config.add_section( 'autotag' )
self.config.set( 'autotag', 'save_on_low_confidence', self.save_on_low_confidence )
self.config.set( 'autotag', 'dont_use_year_when_identifying', self.dont_use_year_when_identifying )
self.config.set( 'autotag', 'assume_1_if_no_issue_num', self.assume_1_if_no_issue_num )
self.config.set( 'autotag', 'ignore_leading_numbers_in_filename', self.ignore_leading_numbers_in_filename )
self.config.set( 'autotag', 'remove_archive_after_successful_match', self.remove_archive_after_successful_match )
self.config.set( 'autotag', 'wait_and_retry_on_rate_limit', self.wait_and_retry_on_rate_limit )
with codecs.open( self.settings_file, 'wb', 'utf8') as configfile:
self.config.write(configfile)
#make sure the basedir is cached, in case we're on windows running a script from frozen binary
ComicTaggerSettings.baseDir()

View File

@ -0,0 +1,258 @@
"""
A PyQT4 dialog to enter app settings
"""
"""
Copyright 2012-2014 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 platform
import os
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from comicvinecacher import ComicVineCacher
from comicvinetalker import ComicVineTalker
from imagefetcher import ImageFetcher
import utils
windowsRarHelp = """
<html><head/><body><p>In order to write to CBR/RAR archives,
you will need to have the tools from
<a href="http://www.win-rar.com/download.html">
<span style=" text-decoration: underline; color:#0000ff;">WinRAR</span>
</a> installed. </p></body></html>
"""
linuxRarHelp = """
<html><head/><body><p>In order to read/write to CBR/RAR archives, you will
need to have the shareware tools from WinRar installed. Your package manager
should have unrar, and probably rar. If not, download them <a href="http://www.win-rar.com/download.html">
<span style=" text-decoration: underline; color:#0000ff;">here</span></a>, and install in your path.</p>
</body></html>
"""
macRarHelp = """
<html><head/><body><p>In order to read/write to CBR/RAR archives,
you will need the shareware tools from <a href="http://www.win-rar.com/download.html">
<span style=" text-decoration: underline; color:#0000ff;">WinRAR</span></a>.
</p></body></html>
"""
class SettingsWindow(QtGui.QDialog):
def __init__(self, parent, settings ):
super(SettingsWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('settingswindow.ui' ), self)
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint )
self.settings = settings
self.name = "Settings"
if platform.system() == "Windows":
self.lblUnrar.hide()
self.leUnrarExePath.hide()
self.btnBrowseUnrar.hide()
self.lblRarHelp.setText( windowsRarHelp )
elif platform.system() == "Linux":
self.lblRarHelp.setText( linuxRarHelp )
elif platform.system() == "Darwin":
self.lblRarHelp.setText( macRarHelp )
self.name = "Preferences"
self.setWindowTitle("ComicTagger " + self.name)
self.lblDefaultSettings.setText( "Revert to default " + self.name.lower())
self.btnResetSettings.setText( "Default " + self.name)
nldtTip = (
""" <html>The <b>Default Name Length Match Tolerance</b> is for eliminating automatic
search matches that are too long compared to your series name search. The higher
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too low, and only the very closest lexical matches will be
explored.</html>""" )
self.leNameLengthDeltaThresh.setToolTip(nldtTip)
pblTip = (
"""<html>
The <b>Publisher Blacklist</b> is for eliminating automatic matches to certain publishers
that you know are incorrect. Useful for avoiding international re-prints with same
covers or series names. Enter publisher names separated by commas.
</html>"""
)
self.tePublisherBlacklist.setToolTip(pblTip)
validator = QtGui.QIntValidator(1, 4, self)
self.leIssueNumPadding.setValidator(validator)
validator = QtGui.QIntValidator(0, 99, self)
self.leNameLengthDeltaThresh.setValidator(validator)
self.settingsToForm()
self.btnBrowseRar.clicked.connect(self.selectRar)
self.btnBrowseUnrar.clicked.connect(self.selectUnrar)
self.btnClearCache.clicked.connect(self.clearCache)
self.btnResetSettings.clicked.connect(self.resetSettings)
self.btnTestKey.clicked.connect(self.testAPIKey)
def settingsToForm( self ):
# Copy values from settings to form
self.leRarExePath.setText( self.settings.rar_exe_path )
self.leUnrarExePath.setText( self.settings.unrar_exe_path )
self.leNameLengthDeltaThresh.setText( str(self.settings.id_length_delta_thresh) )
self.tePublisherBlacklist.setPlainText( self.settings.id_publisher_blacklist )
if self.settings.check_for_new_version:
self.cbxCheckForNewVersion.setCheckState( QtCore.Qt.Checked)
if self.settings.parse_scan_info:
self.cbxParseScanInfo.setCheckState( QtCore.Qt.Checked)
if self.settings.use_series_start_as_volume:
self.cbxUseSeriesStartAsVolume.setCheckState( QtCore.Qt.Checked)
if self.settings.clear_form_before_populating_from_cv:
self.cbxClearFormBeforePopulating.setCheckState( QtCore.Qt.Checked)
if self.settings.remove_html_tables:
self.cbxRemoveHtmlTables.setCheckState( QtCore.Qt.Checked)
self.leKey.setText( str(self.settings.cv_api_key) )
if self.settings.assume_lone_credit_is_primary:
self.cbxAssumeLoneCreditIsPrimary.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_characters_to_tags:
self.cbxCopyCharactersToTags.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_teams_to_tags:
self.cbxCopyTeamsToTags.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_locations_to_tags:
self.cbxCopyLocationsToTags.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_storyarcs_to_tags:
self.cbxCopyStoryArcsToTags.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_notes_to_comments:
self.cbxCopyNotesToComments.setCheckState( QtCore.Qt.Checked)
if self.settings.copy_weblink_to_comments:
self.cbxCopyWebLinkToComments.setCheckState( QtCore.Qt.Checked)
if self.settings.apply_cbl_transform_on_cv_import:
self.cbxApplyCBLTransformOnCVIMport.setCheckState( QtCore.Qt.Checked)
if self.settings.apply_cbl_transform_on_bulk_operation:
self.cbxApplyCBLTransformOnBatchOperation.setCheckState( QtCore.Qt.Checked)
self.leRenameTemplate.setText( self.settings.rename_template )
self.leIssueNumPadding.setText( str(self.settings.rename_issue_number_padding) )
if self.settings.rename_use_smart_string_cleanup:
self.cbxSmartCleanup.setCheckState( QtCore.Qt.Checked )
if self.settings.rename_extension_based_on_archive:
self.cbxChangeExtension.setCheckState( QtCore.Qt.Checked )
def accept( self ):
# Copy values from form to settings and save
self.settings.rar_exe_path = str(self.leRarExePath.text())
self.settings.unrar_exe_path = str(self.leUnrarExePath.text())
# make sure unrar/rar program is now in the path for the UnRAR class
utils.addtopath(os.path.dirname(self.settings.unrar_exe_path))
utils.addtopath(os.path.dirname(self.settings.rar_exe_path))
if not str(self.leNameLengthDeltaThresh.text()).isdigit():
self.leNameLengthDeltaThresh.setText("0")
if not str(self.leIssueNumPadding.text()).isdigit():
self.leIssueNumPadding.setText("0")
self.settings.check_for_new_version = self.cbxCheckForNewVersion.isChecked()
self.settings.id_length_delta_thresh = int(self.leNameLengthDeltaThresh.text())
self.settings.id_publisher_blacklist = str(self.tePublisherBlacklist.toPlainText())
self.settings.parse_scan_info = self.cbxParseScanInfo.isChecked()
self.settings.use_series_start_as_volume = self.cbxUseSeriesStartAsVolume.isChecked()
self.settings.clear_form_before_populating_from_cv = self.cbxClearFormBeforePopulating.isChecked()
self.settings.remove_html_tables = self.cbxRemoveHtmlTables.isChecked()
self.settings.cv_api_key = unicode(self.leKey.text())
ComicVineTalker.api_key = self.settings.cv_api_key
self.settings.assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
self.settings.copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
self.settings.copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
self.settings.copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
self.settings.copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
self.settings.copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
self.settings.copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
self.settings.apply_cbl_transform_on_cv_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
self.settings.apply_cbl_transform_on_bulk_operation = self.cbxApplyCBLTransformOnBatchOperation.isChecked()
self.settings.rename_template = str(self.leRenameTemplate.text())
self.settings.rename_issue_number_padding = int(self.leIssueNumPadding.text())
self.settings.rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked()
self.settings.rename_extension_based_on_archive = self.cbxChangeExtension.isChecked()
self.settings.save()
QtGui.QDialog.accept(self)
def selectRar( self ):
self.selectFile( self.leRarExePath, "RAR" )
def selectUnrar( self ):
self.selectFile( self.leUnrarExePath, "UnRAR" )
def clearCache( self ):
ImageFetcher().clearCache()
ComicVineCacher( ).clearCache()
QtGui.QMessageBox.information(self, self.name, "Cache has been cleared.")
def testAPIKey( self ):
if ComicVineTalker().testKey( unicode(self.leKey.text()) ):
QtGui.QMessageBox.information(self, "API Key Test", "Key is valid!")
else:
QtGui.QMessageBox.warning(self, "API Key Test", "Key is NOT valid.")
def resetSettings( self ):
self.settings.reset()
self.settingsToForm()
QtGui.QMessageBox.information(self, self.name, self.name + " have been returned to default values.")
def selectFile( self, control, name ):
dialog = QtGui.QFileDialog(self)
dialog.setFileMode(QtGui.QFileDialog.ExistingFile)
if platform.system() == "Windows":
if name == "RAR":
filter = self.tr("Rar Program (Rar.exe)")
else:
filter = self.tr("Programs (*.exe)")
dialog.setNameFilter(filter)
else:
dialog.setFilter(QtCore.QDir.Files) #QtCore.QDir.Executable | QtCore.QDir.Files)
pass
dialog.setDirectory(os.path.dirname(str(control.text())))
dialog.setWindowTitle("Find " + name + " program")
if (dialog.exec_()):
fileList = dialog.selectedFiles()
control.setText( str(fileList[0]) )
def showRenameTab( self ):
self.tabWidget.setCurrentIndex(5)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogMatchSelect</class>
<widget class="QDialog" name="dialogMatchSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>943</width>
<height>467</height>
</rect>
</property>
<property name="windowTitle">
<string>Select Match</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QWidget" name="archiveCoverContainer" native="true">
<property name="minimumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="twList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="rowCount">
<number>0</number>
</property>
<property name="columnCount">
<number>4</number>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Series</string>
</property>
</column>
<column>
<property name="text">
<string>Publisher</string>
</property>
</column>
<column>
<property name="text">
<string>Date</string>
</property>
</column>
<column>
<property name="text">
<string>Title</string>
</property>
</column>
</widget>
<widget class="QTextEdit" name="teDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>3</verstretch>
</sizepolicy>
</property>
</widget>
</widget>
</item>
<item>
<widget class="QWidget" name="altCoverContainer" native="true">
<property name="minimumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogMatchSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogMatchSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogIssueSelect</class>
<widget class="QDialog" name="dialogIssueSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>900</width>
<height>413</height>
</rect>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Issue Identification Progress</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<widget class="QWidget" name="archiveCoverContainer">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>110</width>
<height>165</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>110</width>
<height>165</height>
</size>
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QWidget" name="testCoverContainer">
<property name="minimumSize">
<size>
<width>110</width>
<height>165</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>110</width>
<height>165</height>
</size>
</property>
</widget>
</item>
<item row="0" column="2">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>0</number>
</property>
<property name="invertedAppearance">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="textEdit">
<property name="font">
<font>
<family>Courier</family>
</font>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,239 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogExport</class>
<widget class="QDialog" name="dialogExport">
<property name="windowModality">
<enum>Qt::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>519</width>
<height>378</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Auto-Tag</string>
</property>
<property name="modal">
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="6" column="0">
<widget class="QCheckBox" name="cbxSpecifySearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Specify series search string for all selected archives:</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Save on low confidence match</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Ignore leading (sequence) numbers in filename</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="cbxRemoveAfterSuccess">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Remove archives from list after successful tagging</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QCheckBox" name="cbxWaitForRateLimit">
<property name="text">
<string>Wait and retry when Comic Vine rate limit is exceeded (experimental)</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxDontUseYear">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Don't use publication year in indentification process</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxAssumeIssueOne">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>If no issue number, assume &quot;1&quot;</string>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLineEdit" name="leNameLengthMatchTolerance">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLineEdit" name="leSearchString">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QLabel" name="label_3">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Adjust Name Length Match Tolerance:</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogExport</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogExport</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>coverImageWidget</class>
<widget class="QWidget" name="coverImageWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>292</width>
<height>353</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="horizontalSpacing">
<number>0</number>
</property>
<property name="verticalSpacing">
<number>4</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="btnLeft">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnRight">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="0">
<widget class="QFrame" name="frame">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QLabel" name="lblImage">
<property name="geometry">
<rect>
<x>60</x>
<y>50</y>
<width>91</width>
<height>61</height>
</rect>
</property>
<property name="toolTip">
<string>Double-click to expand</string>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogCreditEditor</class>
<widget class="QDialog" name="dialogCreditEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>196</height>
</rect>
</property>
<property name="windowTitle">
<string>Modify Credit</string>
</property>
<property name="sizeGripEnabled">
<bool>false</bool>
</property>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="geometry">
<rect>
<x>180</x>
<y>140</y>
<width>191</width>
<height>30</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
<widget class="QWidget" name="formLayoutWidget">
<property name="geometry">
<rect>
<x>30</x>
<y>30</y>
<width>341</width>
<height>91</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Role</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbRole">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leName"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QCheckBox" name="cbPrimary">
<property name="text">
<string>Primary</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogCreditEditor</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogCreditEditor</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogExport</class>
<widget class="QDialog" name="dialogExport">
<property name="windowModality">
<enum>Qt::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>533</width>
<height>202</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Export to Zip Archive</string>
</property>
<property name="modal">
<bool>false</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QFormLayout" name="formLayout">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="1" column="0" colspan="2">
<widget class="QCheckBox" name="cbxAddToList">
<property name="text">
<string>Add New Archive to ComicTagger list</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="2">
<widget class="QCheckBox" name="cbxDeleteOriginal">
<property name="text">
<string>Delete Original RAR (Not recommended)</string>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>5</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>When Filename already exists:</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QRadioButton" name="radioDontCreate">
<property name="text">
<string>Don't Export</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QRadioButton" name="radioCreateNew">
<property name="text">
<string>Create New Archive With Unique Name (Number appended)</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogExport</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogExport</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>pageListEditor</class>
<widget class="QWidget" name="pageListEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>527</width>
<height>323</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QTableWidget" name="twList">
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="textElideMode">
<enum>Qt::ElideMiddle</enum>
</property>
<attribute name="horizontalHeaderDefaultSectionSize">
<number>61</number>
</attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>36</number>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>File</string>
</property>
<property name="toolTip">
<string>File Name</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>CR</string>
</property>
<property name="toolTip">
<string>Has ComicRack Tags</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>CBL</string>
</property>
<property name="toolTip">
<string>Has ComicBookLover Tags</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>Type</string>
</property>
<property name="toolTip">
<string>Archive Type</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>R/O</string>
</property>
<property name="toolTip">
<string>Read-Only</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>Folder</string>
</property>
<property name="toolTip">
<string>File Location</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QDialog" name="Form">
<property name="windowModality">
<enum>Qt::ApplicationModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>817</width>
<height>455</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<property name="windowOpacity">
<double>1.000000000000000</double>
</property>
<widget class="QLabel" name="lblImage">
<property name="geometry">
<rect>
<x>300</x>
<y>120</y>
<width>66</width>
<height>17</height>
</rect>
</property>
<property name="text">
<string/>
</property>
</widget>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogIssueSelect</class>
<widget class="QDialog" name="dialogIssueSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>872</width>
<height>550</height>
</rect>
</property>
<property name="windowTitle">
<string>Select Issue</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>6</number>
</property>
<item>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="twList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="rowCount">
<number>0</number>
</property>
<property name="columnCount">
<number>3</number>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Issue</string>
</property>
</column>
<column>
<property name="text">
<string>Date</string>
</property>
</column>
<column>
<property name="text">
<string>Title</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
</widget>
<widget class="QTextEdit" name="teDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>3</verstretch>
</sizepolicy>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
</widget>
</widget>
</item>
<item>
<widget class="QWidget" name="coverImageContainer" native="true">
<property name="minimumSize">
<size>
<width>300</width>
<height>450</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>300</width>
<height>450</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogLog</class>
<widget class="QDialog" name="dialogLog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>556</width>
<height>287</height>
</rect>
</property>
<property name="windowTitle">
<string>Issue Identification Progress</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTextEdit" name="textEdit">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Ok</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogLog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogLog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogMatchSelect</class>
<widget class="QDialog" name="dialogMatchSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>943</width>
<height>467</height>
</rect>
</property>
<property name="windowTitle">
<string>Select Match</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QWidget" name="archiveCoverContainer" native="true">
<property name="minimumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="twList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="rowCount">
<number>0</number>
</property>
<property name="columnCount">
<number>4</number>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Series</string>
</property>
</column>
<column>
<property name="text">
<string>Publisher</string>
</property>
</column>
<column>
<property name="text">
<string>Date</string>
</property>
</column>
<column>
<property name="text">
<string>Title</string>
</property>
</column>
</widget>
<widget class="QTextEdit" name="teDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>3</verstretch>
</sizepolicy>
</property>
</widget>
</widget>
</item>
<item>
<widget class="QWidget" name="altCoverContainer" native="true">
<property name="minimumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>350</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogMatchSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogMatchSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogPageBrowser</class>
<widget class="QDialog" name="dialogPageBrowser">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>369</width>
<height>582</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Page Browser</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>4</number>
</property>
<property name="horizontalSpacing">
<number>0</number>
</property>
<property name="verticalSpacing">
<number>2</number>
</property>
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="pageContainer" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>300</height>
</size>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>20</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMaximumSize</enum>
</property>
<property name="topMargin">
<number>4</number>
</property>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="btnPrev">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnNext">
<property name="text">
<string/>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogPageBrowser</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogPageBrowser</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>pageListEditor</class>
<widget class="QWidget" name="pageListEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>527</width>
<height>323</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QListWidget" name="listWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::InternalMove</enum>
</property>
<property name="defaultDropAction">
<enum>Qt::MoveAction</enum>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="btnUp">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>^</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnDown">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>v</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Page Type:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="comboBox"/>
</item>
</layout>
</item>
<item>
<widget class="QWidget" name="pageContainer" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>90</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogIssueSelect</class>
<widget class="QDialog" name="dialogIssueSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>650</width>
<height>287</height>
</rect>
</property>
<property name="windowTitle">
<string>Issue Identification Progress</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>0</number>
</property>
<property name="invertedAppearance">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="textEdit">
<property name="font">
<font>
<family>Courier</family>
</font>
</property> <property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogIssueSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogRename</class>
<widget class="QDialog" name="dialogRename">
<property name="windowModality">
<enum>Qt::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>801</width>
<height>360</height>
</rect>
</property>
<property name="windowTitle">
<string>Archive Rename</string>
</property>
<property name="modal">
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0" colspan="2">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string> Preview:</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QTableWidget" name="twList">
<property name="selectionMode">
<enum>QAbstractItemView::NoSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="textElideMode">
<enum>Qt::ElideMiddle</enum>
</property>
<column>
<property name="text">
<string>Folder</string>
</property>
</column>
<column>
<property name="text">
<string>Old Name</string>
</property>
</column>
<column>
<property name="text">
<string>New Name</string>
</property>
</column>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="btnSettings">
<property name="text">
<string>Rename Settings</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogRename</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogRename</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>346</x>
<y>187</y>
</hint>
<hint type="destinationlabel">
<x>277</x>
<y>104</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,742 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>dialogCreditEditor</class>
<widget class="QDialog" name="dialogCreditEditor">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>702</width>
<height>432</height>
</rect>
</property>
<property name="windowTitle">
<string>Settings</string>
</property>
<property name="sizeGripEnabled">
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab">
<attribute name="title">
<string>General</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="grpBoxBtns">
<property name="title">
<string/>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="1" column="0">
<widget class="QPushButton" name="btnResetSettings">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Default Settings</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="lblDefaultSettings">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Revert to default settings</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="btnClearCache">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Clear Cache</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="label_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>If you need to free up the disk space, or the responses seems out of date, clear the online cache.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0" colspan="2">
<widget class="QCheckBox" name="cbxCheckForNewVersion">
<property name="text">
<string>Check for new version on startup</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="Line" name="line_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="grpBoxRar">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="lblRarHelp">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;In order to read/write to CBR/RAR archives, you will need to have the shareware tools from &lt;a href=&quot;www.win-rar.com/download.html&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;WinRAR&lt;/span&gt;&lt;/a&gt; installed. &lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="textFormat">
<enum>Qt::RichText</enum>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_7">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>RAR program</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leRarExePath">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QPushButton" name="btnBrowseRar">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="lblUnrar">
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>UnRAR program</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leUnrarExePath">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QPushButton" name="btnBrowseUnrar">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Identifier</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;These settings are for the automatic issue identifier which searches online for matches. They will not affect &amp;quot;manual&amp;quot; searching.&lt;/p&gt;&lt;p&gt;Hover the mouse over an entry field for more info.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<layout class="QFormLayout" name="formLayout_2">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="toolTip">
<string/>
</property>
<property name="text">
<string>Default Name Length Match Tolerance:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="leNameLengthDeltaThresh">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Publisher Blacklist:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPlainTextEdit" name="tePublisherBlacklist">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_6">
<attribute name="title">
<string>Filename Parser</string>
</attribute>
<widget class="QCheckBox" name="cbxParseScanInfo">
<property name="geometry">
<rect>
<x>30</x>
<y>30</y>
<width>421</width>
<height>25</height>
</rect>
</property>
<property name="text">
<string>Parse Scan Info From Filename (Experimental)</string>
</property>
</widget>
</widget>
<widget class="QWidget" name="tab_3">
<attribute name="title">
<string>Comic Vine</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="grpBoxCVTop">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QCheckBox" name="cbxUseSeriesStartAsVolume">
<property name="text">
<string>Use Series Start Date as Volume</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxClearFormBeforePopulating">
<property name="text">
<string>Clear Form Before Importing Comic Vine data</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cbxRemoveHtmlTables">
<property name="text">
<string>Remove HTML tables from CV summary field</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="Line" name="line_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="grpBoxKey">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout_8">
<item row="1" column="1">
<widget class="QLineEdit" name="leKey">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="readOnly">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Comic Vine API Key</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="lblKeyHelp">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;A personal API key from &lt;a href=&quot;http://www.comicvine.com/api/&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;Comic Vine&lt;/span&gt;&lt;/a&gt; is recommended in order to search for tag data. Login (or create a new account) there to get your key, and enter it below.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="textFormat">
<enum>Qt::RichText</enum>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QPushButton" name="btnTestKey">
<property name="text">
<string>Tesk Key</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_4">
<attribute name="title">
<string>CBL</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_6">
<item row="0" column="0">
<widget class="QCheckBox" name="cbxApplyCBLTransformOnCVIMport">
<property name="text">
<string>Apply CBL Transforms on ComicVine Import</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxApplyCBLTransformOnBatchOperation">
<property name="text">
<string>Apply CBL Transforms on Batch Copy Operations to CBL Tags</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QGroupBox" name="groupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>CBL Transforms</string>
</property>
<widget class="QWidget" name="layoutWidget">
<property name="geometry">
<rect>
<x>11</x>
<y>21</y>
<width>251</width>
<height>192</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout_7">
<item row="3" column="0">
<widget class="QCheckBox" name="cbxCopyLocationsToTags">
<property name="text">
<string>Copy Locations to Generic Tags</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="cbxAssumeLoneCreditIsPrimary">
<property name="text">
<string>Assume Lone Credit Is Primary</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="cbxCopyCharactersToTags">
<property name="text">
<string>Copy Characters to Generic Tags</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="cbxCopyTeamsToTags">
<property name="text">
<string>Copy Teams to Generic Tags</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="cbxCopyNotesToComments">
<property name="text">
<string>Copy Notes to Comments</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QCheckBox" name="cbxCopyWebLinkToComments">
<property name="text">
<string>Copy Web Link to Comments</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="cbxCopyStoryArcsToTags">
<property name="text">
<string>Copy Story Arcs to Generic Tags</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item row="2" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_5">
<attribute name="title">
<string>Rename</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="0">
<layout class="QFormLayout" name="formLayout_3">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Template:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leRenameTemplate">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;The template for the new filename. Accepts the following variables:&lt;/p&gt;&lt;p&gt;%series%&lt;br/&gt;%issue%&lt;br/&gt;%volume%&lt;br/&gt;%issuecount%&lt;br/&gt;%year%&lt;br/&gt;%month%&lt;br/&gt;%month_name%&lt;br/&gt;%publisher%&lt;br/&gt;%title%&lt;br/&gt;
%genre%&lt;br/&gt;
%language_code%&lt;br/&gt;
%criticalrating%&lt;br/&gt;
%alternateseries%&lt;br/&gt;
%alternatenumber%&lt;br/&gt;
%alternatecount%&lt;br/&gt;
%imprint%&lt;br/&gt;
%format%&lt;br/&gt;
%maturityrating%&lt;br/&gt;
%storyarc%&lt;br/&gt;
%seriesgroup%&lt;br/&gt;
%scaninfo%
&lt;/p&gt;&lt;p&gt;Examples:&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-style:italic;&quot;&gt;%series% %issue% (%year%)&lt;/span&gt;&lt;br/&gt;&lt;span style=&quot; font-style:italic;&quot;&gt;%series% #%issue% - %title%&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Issue # Zero Padding</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leIssueNumPadding">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;Issue # Zero Padding&lt;/span&gt; dictates if the issue number should be padded on left with zeros. A value of 2, for example, means that the number will always be at least two digits.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QCheckBox" name="cbxSmartCleanup">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;&amp;quot;Smart Text Cleanup&amp;quot; &lt;/span&gt;will attempt to clean up the new filename if there are missing fields from the template. For example, removing empty braces, repeated spaces and dashes, and more. Experimental feature.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Use Smart Text Cleanup (Experimental)</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="QCheckBox" name="cbxChangeExtension">
<property name="text">
<string>Change Extension Based On Archive Type</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>dialogCreditEditor</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>dialogCreditEditor</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SelectSeries</class>
<widget class="QDialog" name="SelectSeries">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>849</width>
<height>476</height>
</rect>
</property>
<property name="windowTitle">
<string>Select Series</string>
</property>
<property name="sizeGripEnabled">
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QWidget" name="imageContainer" native="true">
<property name="minimumSize">
<size>
<width>300</width>
<height>450</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>300</width>
<height>450</height>
</size>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QTableWidget" name="twList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="rowCount">
<number>0</number>
</property>
<property name="columnCount">
<number>4</number>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Series</string>
</property>
</column>
<column>
<property name="text">
<string>Year</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>Issues</string>
</property>
<property name="textAlignment">
<set>AlignHCenter|AlignVCenter|AlignCenter</set>
</property>
</column>
<column>
<property name="text">
<string>Publisher</string>
</property>
</column>
</widget>
<widget class="QTextEdit" name="teDetails">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>3</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="btnAutoSelect">
<property name="text">
<string>Auto-Identify</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnRequery">
<property name="text">
<string>Re-Search</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnIssues">
<property name="text">
<string>Show Issues</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>SelectSeries</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>SelectSeries</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

663
lib/comictaggerlib/utils.py Normal file
View File

@ -0,0 +1,663 @@
# coding=utf-8
"""
Some generic utilities
"""
"""
Copyright 2012-2014 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 sys
import os
import re
import platform
import locale
import codecs
from settings import ComicTaggerSettings
class UtilsVars:
already_fixed_encoding = False
def get_actual_preferred_encoding():
preferred_encoding = locale.getpreferredencoding()
if platform.system() == "Darwin":
preferred_encoding = "utf-8"
return preferred_encoding
def fix_output_encoding( ):
if not UtilsVars.already_fixed_encoding:
# this reads the environment and inits the right locale
locale.setlocale(locale.LC_ALL, "")
# try to make stdout/stderr encodings happy for unicode printing
preferred_encoding = get_actual_preferred_encoding()
sys.stdout = codecs.getwriter(preferred_encoding)(sys.stdout)
sys.stderr = codecs.getwriter(preferred_encoding)(sys.stderr)
UtilsVars.already_fixed_encoding = True
def get_recursive_filelist( pathlist ):
"""
Get a recursive list of of all files under all path items in the list
"""
filename_encoding = sys.getfilesystemencoding()
filelist = []
for p in pathlist:
# if path is a folder, walk it recursivly, and all files underneath
if type(p) == str:
#make sure string is unicode
p = p.decode(filename_encoding) #, 'replace')
elif type(p) != unicode:
#it's probably a QString
p = unicode(p)
if os.path.isdir( p ):
for root,dirs,files in os.walk( p ):
for f in files:
if type(f) == str:
#make sure string is unicode
f = f.decode(filename_encoding, 'replace')
elif type(f) != unicode:
#it's probably a QString
f = unicode(f)
filelist.append(os.path.join(root,f))
else:
filelist.append(p)
return filelist
def listToString( l ):
string = ""
if l is not None:
for item in l:
if len(string) > 0:
string += ", "
string += item
return string
def addtopath( dirname ):
if dirname is not None and dirname != "":
# verify that path doesn't already contain the given dirname
tmpdirname = re.escape(dirname)
pattern = r"{sep}{dir}$|^{dir}{sep}|{sep}{dir}{sep}|^{dir}$".format( dir=tmpdirname, sep=os.pathsep)
match = re.search(pattern, os.environ['PATH'])
if not match:
os.environ['PATH'] = dirname + os.pathsep + os.environ['PATH']
# returns executable path, if it exists
def which(program):
def is_exe(fpath):
return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
fpath, fname = os.path.split(program)
if fpath:
if is_exe(program):
return program
else:
for path in os.environ["PATH"].split(os.pathsep):
exe_file = os.path.join(path, program)
if is_exe(exe_file):
return exe_file
return None
def removearticles( text ):
text = text.lower()
articles = ['and', 'the', 'a', '&', 'issue' ]
newText = ''
for word in text.split(' '):
if word not in articles:
newText += word+' '
newText = newText[:-1]
# now get rid of some other junk
newText = newText.replace(":", "")
newText = newText.replace(",", "")
newText = newText.replace("-", " ")
# since the CV api changed, searches for series names with periods
# now explicity require the period to be in the search key,
# so the line below is removed (for now)
#newText = newText.replace(".", "")
return newText
def unique_file(file_name):
counter = 1
file_name_parts = os.path.splitext(file_name) # returns ('/path/file', '.ext')
while 1:
if not os.path.lexists( file_name):
return file_name
file_name = file_name_parts[0] + ' (' + str(counter) + ')' + file_name_parts[1]
counter += 1
# -o- coding: utf-8 -o-
# ISO639 python dict
# oficial list in http://www.loc.gov/standards/iso639-2/php/code_list.php
lang_dict = {
'ab': 'Abkhaz',
'aa': 'Afar',
'af': 'Afrikaans',
'ak': 'Akan',
'sq': 'Albanian',
'am': 'Amharic',
'ar': 'Arabic',
'an': 'Aragonese',
'hy': 'Armenian',
'as': 'Assamese',
'av': 'Avaric',
'ae': 'Avestan',
'ay': 'Aymara',
'az': 'Azerbaijani',
'bm': 'Bambara',
'ba': 'Bashkir',
'eu': 'Basque',
'be': 'Belarusian',
'bn': 'Bengali',
'bh': 'Bihari',
'bi': 'Bislama',
'bs': 'Bosnian',
'br': 'Breton',
'bg': 'Bulgarian',
'my': 'Burmese',
'ca': 'Catalan; Valencian',
'ch': 'Chamorro',
'ce': 'Chechen',
'ny': 'Chichewa; Chewa; Nyanja',
'zh': 'Chinese',
'cv': 'Chuvash',
'kw': 'Cornish',
'co': 'Corsican',
'cr': 'Cree',
'hr': 'Croatian',
'cs': 'Czech',
'da': 'Danish',
'dv': 'Divehi; Maldivian;',
'nl': 'Dutch',
'dz': 'Dzongkha',
'en': 'English',
'eo': 'Esperanto',
'et': 'Estonian',
'ee': 'Ewe',
'fo': 'Faroese',
'fj': 'Fijian',
'fi': 'Finnish',
'fr': 'French',
'ff': 'Fula',
'gl': 'Galician',
'ka': 'Georgian',
'de': 'German',
'el': 'Greek, Modern',
'gn': 'Guaraní',
'gu': 'Gujarati',
'ht': 'Haitian',
'ha': 'Hausa',
'he': 'Hebrew (modern)',
'hz': 'Herero',
'hi': 'Hindi',
'ho': 'Hiri Motu',
'hu': 'Hungarian',
'ia': 'Interlingua',
'id': 'Indonesian',
'ie': 'Interlingue',
'ga': 'Irish',
'ig': 'Igbo',
'ik': 'Inupiaq',
'io': 'Ido',
'is': 'Icelandic',
'it': 'Italian',
'iu': 'Inuktitut',
'ja': 'Japanese',
'jv': 'Javanese',
'kl': 'Kalaallisut',
'kn': 'Kannada',
'kr': 'Kanuri',
'ks': 'Kashmiri',
'kk': 'Kazakh',
'km': 'Khmer',
'ki': 'Kikuyu, Gikuyu',
'rw': 'Kinyarwanda',
'ky': 'Kirghiz, Kyrgyz',
'kv': 'Komi',
'kg': 'Kongo',
'ko': 'Korean',
'ku': 'Kurdish',
'kj': 'Kwanyama, Kuanyama',
'la': 'Latin',
'lb': 'Luxembourgish',
'lg': 'Luganda',
'li': 'Limburgish',
'ln': 'Lingala',
'lo': 'Lao',
'lt': 'Lithuanian',
'lu': 'Luba-Katanga',
'lv': 'Latvian',
'gv': 'Manx',
'mk': 'Macedonian',
'mg': 'Malagasy',
'ms': 'Malay',
'ml': 'Malayalam',
'mt': 'Maltese',
'mi': 'Māori',
'mr': 'Marathi (Marāṭhī)',
'mh': 'Marshallese',
'mn': 'Mongolian',
'na': 'Nauru',
'nv': 'Navajo, Navaho',
'nb': 'Norwegian Bokmål',
'nd': 'North Ndebele',
'ne': 'Nepali',
'ng': 'Ndonga',
'nn': 'Norwegian Nynorsk',
'no': 'Norwegian',
'ii': 'Nuosu',
'nr': 'South Ndebele',
'oc': 'Occitan',
'oj': 'Ojibwe, Ojibwa',
'cu': 'Old Church Slavonic',
'om': 'Oromo',
'or': 'Oriya',
'os': 'Ossetian, Ossetic',
'pa': 'Panjabi, Punjabi',
'pi': 'Pāli',
'fa': 'Persian',
'pl': 'Polish',
'ps': 'Pashto, Pushto',
'pt': 'Portuguese',
'qu': 'Quechua',
'rm': 'Romansh',
'rn': 'Kirundi',
'ro': 'Romanian, Moldavan',
'ru': 'Russian',
'sa': 'Sanskrit (Saṁskṛta)',
'sc': 'Sardinian',
'sd': 'Sindhi',
'se': 'Northern Sami',
'sm': 'Samoan',
'sg': 'Sango',
'sr': 'Serbian',
'gd': 'Scottish Gaelic',
'sn': 'Shona',
'si': 'Sinhala, Sinhalese',
'sk': 'Slovak',
'sl': 'Slovene',
'so': 'Somali',
'st': 'Southern Sotho',
'es': 'Spanish; Castilian',
'su': 'Sundanese',
'sw': 'Swahili',
'ss': 'Swati',
'sv': 'Swedish',
'ta': 'Tamil',
'te': 'Telugu',
'tg': 'Tajik',
'th': 'Thai',
'ti': 'Tigrinya',
'bo': 'Tibetan',
'tk': 'Turkmen',
'tl': 'Tagalog',
'tn': 'Tswana',
'to': 'Tonga',
'tr': 'Turkish',
'ts': 'Tsonga',
'tt': 'Tatar',
'tw': 'Twi',
'ty': 'Tahitian',
'ug': 'Uighur, Uyghur',
'uk': 'Ukrainian',
'ur': 'Urdu',
'uz': 'Uzbek',
've': 'Venda',
'vi': 'Vietnamese',
'vo': 'Volapük',
'wa': 'Walloon',
'cy': 'Welsh',
'wo': 'Wolof',
'fy': 'Western Frisian',
'xh': 'Xhosa',
'yi': 'Yiddish',
'yo': 'Yoruba',
'za': 'Zhuang, Chuang',
'zu': 'Zulu',
}
countries = [
('AF', 'Afghanistan'),
('AL', 'Albania'),
('DZ', 'Algeria'),
('AS', 'American Samoa'),
('AD', 'Andorra'),
('AO', 'Angola'),
('AI', 'Anguilla'),
('AQ', 'Antarctica'),
('AG', 'Antigua And Barbuda'),
('AR', 'Argentina'),
('AM', 'Armenia'),
('AW', 'Aruba'),
('AU', 'Australia'),
('AT', 'Austria'),
('AZ', 'Azerbaijan'),
('BS', 'Bahamas'),
('BH', 'Bahrain'),
('BD', 'Bangladesh'),
('BB', 'Barbados'),
('BY', 'Belarus'),
('BE', 'Belgium'),
('BZ', 'Belize'),
('BJ', 'Benin'),
('BM', 'Bermuda'),
('BT', 'Bhutan'),
('BO', 'Bolivia'),
('BA', 'Bosnia And Herzegowina'),
('BW', 'Botswana'),
('BV', 'Bouvet Island'),
('BR', 'Brazil'),
('BN', 'Brunei Darussalam'),
('BG', 'Bulgaria'),
('BF', 'Burkina Faso'),
('BI', 'Burundi'),
('KH', 'Cambodia'),
('CM', 'Cameroon'),
('CA', 'Canada'),
('CV', 'Cape Verde'),
('KY', 'Cayman Islands'),
('CF', 'Central African Rep'),
('TD', 'Chad'),
('CL', 'Chile'),
('CN', 'China'),
('CX', 'Christmas Island'),
('CC', 'Cocos Islands'),
('CO', 'Colombia'),
('KM', 'Comoros'),
('CG', 'Congo'),
('CK', 'Cook Islands'),
('CR', 'Costa Rica'),
('CI', 'Cote D`ivoire'),
('HR', 'Croatia'),
('CU', 'Cuba'),
('CY', 'Cyprus'),
('CZ', 'Czech Republic'),
('DK', 'Denmark'),
('DJ', 'Djibouti'),
('DM', 'Dominica'),
('DO', 'Dominican Republic'),
('TP', 'East Timor'),
('EC', 'Ecuador'),
('EG', 'Egypt'),
('SV', 'El Salvador'),
('GQ', 'Equatorial Guinea'),
('ER', 'Eritrea'),
('EE', 'Estonia'),
('ET', 'Ethiopia'),
('FK', 'Falkland Islands (Malvinas)'),
('FO', 'Faroe Islands'),
('FJ', 'Fiji'),
('FI', 'Finland'),
('FR', 'France'),
('GF', 'French Guiana'),
('PF', 'French Polynesia'),
('TF', 'French S. Territories'),
('GA', 'Gabon'),
('GM', 'Gambia'),
('GE', 'Georgia'),
('DE', 'Germany'),
('GH', 'Ghana'),
('GI', 'Gibraltar'),
('GR', 'Greece'),
('GL', 'Greenland'),
('GD', 'Grenada'),
('GP', 'Guadeloupe'),
('GU', 'Guam'),
('GT', 'Guatemala'),
('GN', 'Guinea'),
('GW', 'Guinea-bissau'),
('GY', 'Guyana'),
('HT', 'Haiti'),
('HN', 'Honduras'),
('HK', 'Hong Kong'),
('HU', 'Hungary'),
('IS', 'Iceland'),
('IN', 'India'),
('ID', 'Indonesia'),
('IR', 'Iran'),
('IQ', 'Iraq'),
('IE', 'Ireland'),
('IL', 'Israel'),
('IT', 'Italy'),
('JM', 'Jamaica'),
('JP', 'Japan'),
('JO', 'Jordan'),
('KZ', 'Kazakhstan'),
('KE', 'Kenya'),
('KI', 'Kiribati'),
('KP', 'Korea (North)'),
('KR', 'Korea (South)'),
('KW', 'Kuwait'),
('KG', 'Kyrgyzstan'),
('LA', 'Laos'),
('LV', 'Latvia'),
('LB', 'Lebanon'),
('LS', 'Lesotho'),
('LR', 'Liberia'),
('LY', 'Libya'),
('LI', 'Liechtenstein'),
('LT', 'Lithuania'),
('LU', 'Luxembourg'),
('MO', 'Macau'),
('MK', 'Macedonia'),
('MG', 'Madagascar'),
('MW', 'Malawi'),
('MY', 'Malaysia'),
('MV', 'Maldives'),
('ML', 'Mali'),
('MT', 'Malta'),
('MH', 'Marshall Islands'),
('MQ', 'Martinique'),
('MR', 'Mauritania'),
('MU', 'Mauritius'),
('YT', 'Mayotte'),
('MX', 'Mexico'),
('FM', 'Micronesia'),
('MD', 'Moldova'),
('MC', 'Monaco'),
('MN', 'Mongolia'),
('MS', 'Montserrat'),
('MA', 'Morocco'),
('MZ', 'Mozambique'),
('MM', 'Myanmar'),
('NA', 'Namibia'),
('NR', 'Nauru'),
('NP', 'Nepal'),
('NL', 'Netherlands'),
('AN', 'Netherlands Antilles'),
('NC', 'New Caledonia'),
('NZ', 'New Zealand'),
('NI', 'Nicaragua'),
('NE', 'Niger'),
('NG', 'Nigeria'),
('NU', 'Niue'),
('NF', 'Norfolk Island'),
('MP', 'Northern Mariana Islands'),
('NO', 'Norway'),
('OM', 'Oman'),
('PK', 'Pakistan'),
('PW', 'Palau'),
('PA', 'Panama'),
('PG', 'Papua New Guinea'),
('PY', 'Paraguay'),
('PE', 'Peru'),
('PH', 'Philippines'),
('PN', 'Pitcairn'),
('PL', 'Poland'),
('PT', 'Portugal'),
('PR', 'Puerto Rico'),
('QA', 'Qatar'),
('RE', 'Reunion'),
('RO', 'Romania'),
('RU', 'Russian Federation'),
('RW', 'Rwanda'),
('KN', 'Saint Kitts And Nevis'),
('LC', 'Saint Lucia'),
('VC', 'St Vincent/Grenadines'),
('WS', 'Samoa'),
('SM', 'San Marino'),
('ST', 'Sao Tome'),
('SA', 'Saudi Arabia'),
('SN', 'Senegal'),
('SC', 'Seychelles'),
('SL', 'Sierra Leone'),
('SG', 'Singapore'),
('SK', 'Slovakia'),
('SI', 'Slovenia'),
('SB', 'Solomon Islands'),
('SO', 'Somalia'),
('ZA', 'South Africa'),
('ES', 'Spain'),
('LK', 'Sri Lanka'),
('SH', 'St. Helena'),
('PM', 'St.Pierre'),
('SD', 'Sudan'),
('SR', 'Suriname'),
('SZ', 'Swaziland'),
('SE', 'Sweden'),
('CH', 'Switzerland'),
('SY', 'Syrian Arab Republic'),
('TW', 'Taiwan'),
('TJ', 'Tajikistan'),
('TZ', 'Tanzania'),
('TH', 'Thailand'),
('TG', 'Togo'),
('TK', 'Tokelau'),
('TO', 'Tonga'),
('TT', 'Trinidad And Tobago'),
('TN', 'Tunisia'),
('TR', 'Turkey'),
('TM', 'Turkmenistan'),
('TV', 'Tuvalu'),
('UG', 'Uganda'),
('UA', 'Ukraine'),
('AE', 'United Arab Emirates'),
('UK', 'United Kingdom'),
('US', 'United States'),
('UY', 'Uruguay'),
('UZ', 'Uzbekistan'),
('VU', 'Vanuatu'),
('VA', 'Vatican City State'),
('VE', 'Venezuela'),
('VN', 'Viet Nam'),
('VG', 'Virgin Islands (British)'),
('VI', 'Virgin Islands (U.S.)'),
('EH', 'Western Sahara'),
('YE', 'Yemen'),
('YU', 'Yugoslavia'),
('ZR', 'Zaire'),
('ZM', 'Zambia'),
('ZW', 'Zimbabwe')
]
def getLanguageDict():
return lang_dict
def getLanguageFromISO( iso ):
if iso == None:
return None
else:
return lang_dict[ iso ]
try:
from PyQt4 import QtGui
qt_available = True
except ImportError:
qt_available = False
if qt_available:
def reduceWidgetFontSize( widget , delta = 2):
f = widget.font()
if f.pointSize() > 10:
f.setPointSize( f.pointSize() - delta )
widget.setFont( f )
def centerWindowOnScreen( window ):
"""
Center the window on screen. This implemention will handle the window
being resized or the screen resolution changing.
"""
# Get the current screens' dimensions...
screen = QtGui.QDesktopWidget().screenGeometry()
# ... and get this windows' dimensions
mysize = window.geometry()
# The horizontal position is calulated as screenwidth - windowwidth /2
hpos = ( screen.width() - window.width() ) / 2
# And vertical position the same, but with the height dimensions
vpos = ( screen.height() - window.height() ) / 2
# And the move call repositions the window
window.move(hpos, vpos)
def centerWindowOnParent( window ):
top_level = window
while top_level.parent() is not None:
top_level = top_level.parent()
# Get the current screens' dimensions...
main_window_size = top_level.geometry()
# ... and get this windows' dimensions
mysize = window.geometry()
# The horizontal position is calulated as screenwidth - windowwidth /2
hpos = ( main_window_size.width() - window.width() ) / 2
# And vertical position the same, but with the height dimensions
vpos = ( main_window_size.height() - window.height() ) / 2
# And the move call repositions the window
window.move(hpos + main_window_size.left(), vpos + main_window_size.top())
try:
from PIL import Image
from PIL import WebPImagePlugin
import StringIO
pil_available = True
except ImportError:
pil_available = False
def getQImageFromData(image_data):
img = QtGui.QImage()
success = img.loadFromData( image_data )
if not success:
try:
if pil_available:
# Qt doesn't understand the format, but maybe PIL does
# so try to convert the image data to uncompressed tiff format
im = Image.open(StringIO.StringIO(image_data))
output = StringIO.StringIO()
im.save(output, format="TIFF")
img.loadFromData( output.getvalue() )
success = True
except Exception as e:
pass
# if still nothing, go with default image
if not success:
img.load(ComicTaggerSettings.getGraphic('nocover.png'))
return img

View File

@ -0,0 +1,94 @@
"""
Version checker
"""
"""
Copyright 2013 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 sys
import os
import platform
import urllib,urllib2
import ctversion
try:
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject():
def __init__(self,*args):
pass
class pyqtSignal():
def __init__(self,*args):
pass
def emit(a,b,c):
pass
class VersionChecker(QObject):
def getRequestUrl( self, uuid, use_stats ):
base_url = "http://comictagger1.appspot.com/latest"
args = ""
if use_stats:
if platform.system() == "Windows":
plat = "win"
elif platform.system() == "Linux":
plat = "lin"
elif platform.system() == "Darwin":
plat = "mac"
else:
plat = "other"
args = "?uuid={0}&platform={1}&version={2}".format(uuid, plat, ctversion.version)
if not getattr(sys, 'frozen', None):
args += "&src=T"
return base_url+args
def getLatestVersion( self, uuid, use_stats=True):
try:
resp = urllib2.urlopen( self.getRequestUrl(uuid, use_stats ))
new_version = resp.read()
except Exception as e:
return None
if new_version is None or new_version == "":
return None
return new_version.strip()
versionRequestComplete = pyqtSignal( str )
def asyncGetLatestVersion( self, uuid, use_stats ):
url = self.getRequestUrl( uuid, use_stats )
self.nam = QNetworkAccessManager()
self.nam.finished.connect( self.asyncGetLatestVersionComplete )
self.nam.get(QNetworkRequest(QUrl(str(url))))
def asyncGetLatestVersionComplete( self, reply ):
if (reply.error() != QNetworkReply.NoError):
return
# read in the response
new_version = str(reply.readAll())
if new_version is None or new_version == "":
return
self.versionRequestComplete.emit( new_version.strip() )

View File

@ -0,0 +1,385 @@
"""
A PyQT4 dialog to select specific series/volume from list
"""
"""
Copyright 2012-2014 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 sys
import time
import os
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QObject
from PyQt4.QtCore import QUrl,pyqtSignal
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from issueselectionwindow import IssueSelectionWindow
from issueidentifier import IssueIdentifier
from genericmetadata import GenericMetadata
from imagefetcher import ImageFetcher
from progresswindow import IDProgressWindow
from settings import ComicTaggerSettings
from matchselectionwindow import MatchSelectionWindow
from coverimagewidget import CoverImageWidget
import utils
class SearchThread( QtCore.QThread):
searchComplete = pyqtSignal()
progressUpdate = pyqtSignal(int, int)
def __init__(self, series_name, refresh):
QtCore.QThread.__init__(self)
self.series_name = series_name
self.refresh = refresh
self.error_code = None
def run(self):
comicVine = ComicVineTalker()
try:
self.cv_error = False
self.cv_search_results = comicVine.searchForSeries( self.series_name, callback=self.prog_callback, refresh_cache=self.refresh )
except ComicVineTalkerException as e:
self.cv_search_results = []
self.cv_error = True
self.error_code = e.code
finally:
self.searchComplete.emit()
def prog_callback(self, current, total):
self.progressUpdate.emit(current, total)
class IdentifyThread( QtCore.QThread):
identifyComplete = pyqtSignal( )
identifyLogMsg = pyqtSignal( str )
identifyProgress = pyqtSignal( int, int )
def __init__(self, identifier):
QtCore.QThread.__init__(self)
self.identifier = identifier
self.identifier.setOutputFunction( self.logOutput )
self.identifier.setProgressCallback( self.progressCallback )
def logOutput(self, text):
self.identifyLogMsg.emit( text )
def progressCallback(self, cur, total):
self.identifyProgress.emit( cur, total )
def run(self):
matches =self.identifier.search()
self.identifyComplete.emit( )
class VolumeSelectionWindow(QtGui.QDialog):
def __init__(self, parent, series_name, issue_number, year, issue_count, cover_index_list, comic_archive, settings, autoselect=False):
super(VolumeSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('volumeselectionwindow.ui' ), self)
self.imageWidget = CoverImageWidget( self.imageContainer, CoverImageWidget.URLMode )
gridlayout = QtGui.QGridLayout( self.imageContainer )
gridlayout.addWidget( self.imageWidget )
gridlayout.setContentsMargins(0,0,0,0)
utils.reduceWidgetFontSize( self.teDetails, 1 )
utils.reduceWidgetFontSize( self.twList )
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.settings = settings
self.series_name = series_name
self.issue_number = issue_number
self.year = year
self.issue_count = issue_count
self.volume_id = 0
self.comic_archive = comic_archive
self.immediate_autoselect = autoselect
self.cover_index_list = cover_index_list
self.cv_search_results = None
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.btnRequery.clicked.connect(self.requery)
self.btnIssues.clicked.connect(self.showIssues)
self.btnAutoSelect.clicked.connect(self.autoSelect)
self.updateButtons()
self.performQuery()
self.twList.selectRow(0)
def updateButtons( self ):
if self.cv_search_results is not None and len(self.cv_search_results) > 0:
enabled = True
else:
enabled = False
self.btnRequery.setEnabled( enabled )
self.btnIssues.setEnabled( enabled )
self.btnAutoSelect.setEnabled( enabled )
self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setEnabled( enabled )
def requery( self, ):
self.performQuery( refresh=True )
self.twList.selectRow(0)
def autoSelect( self ):
if self.comic_archive is None:
QtGui.QMessageBox.information(self,"Auto-Select", "You need to load a comic first!")
return
if self.issue_number is None or self.issue_number == "":
QtGui.QMessageBox.information(self,"Auto-Select", "Can't auto-select without an issue number (yet!)")
return
self.iddialog = IDProgressWindow( self)
self.iddialog.setModal(True)
self.iddialog.rejected.connect( self.identifyCancel )
self.iddialog.show()
self.ii = IssueIdentifier( self.comic_archive, self.settings )
md = GenericMetadata()
md.series = self.series_name
md.issue = self.issue_number
md.year = self.year
md.issueCount = self.issue_count
self.ii.setAdditionalMetadata( md )
self.ii.onlyUseAdditionalMetaData = True
self.ii.cover_page_index = int(self.cover_index_list[0])
self.id_thread = IdentifyThread( self.ii )
self.id_thread.identifyComplete.connect( self.identifyComplete )
self.id_thread.identifyLogMsg.connect( self.logIDOutput )
self.id_thread.identifyProgress.connect( self.identifyProgress )
self.id_thread.start()
self.iddialog.exec_()
def logIDOutput( self, text ):
print unicode(text),
self.iddialog.textEdit.ensureCursorVisible()
self.iddialog.textEdit.insertPlainText(text)
def identifyProgress( self, cur, total ):
self.iddialog.progressBar.setMaximum( total )
self.iddialog.progressBar.setValue( cur )
def identifyCancel( self ):
self.ii.cancel = True
def identifyComplete( self ):
matches = self.ii.match_list
result = self.ii.search_result
match_index = 0
found_match = None
choices = False
if result == self.ii.ResultNoMatches:
QtGui.QMessageBox.information(self,"Auto-Select Result", " No matches found :-(")
elif result == self.ii.ResultFoundMatchButBadCoverScore:
QtGui.QMessageBox.information(self,"Auto-Select Result", " Found a match, but cover doesn't seem the same. Verify before commiting!")
found_match = matches[0]
elif result == self.ii.ResultFoundMatchButNotFirstPage :
QtGui.QMessageBox.information(self,"Auto-Select Result", " Found a match, but not with the first page of the archive.")
found_match = matches[0]
elif result == self.ii.ResultMultipleMatchesWithBadImageScores:
QtGui.QMessageBox.information(self,"Auto-Select Result", " Found some possibilities, but no confidence. Proceed manually.")
choices = True
elif result == self.ii.ResultOneGoodMatch:
found_match = matches[0]
elif result == self.ii.ResultMultipleGoodMatches:
QtGui.QMessageBox.information(self,"Auto-Select Result", " Found multiple likely matches. Please select.")
choices = True
if choices:
selector = MatchSelectionWindow( self, matches, self.comic_archive )
selector.setModal(True)
selector.exec_()
if selector.result():
#we should now have a list index
found_match = selector.currentMatch()
if found_match is not None:
self.iddialog.accept()
self.volume_id = found_match['volume_id']
self.issue_number = found_match['issue_number']
self.selectByID()
self.showIssues()
def showIssues( self ):
selector = IssueSelectionWindow( self, self.settings, self.volume_id, self.issue_number )
title = ""
for record in self.cv_search_results:
if record['id'] == self.volume_id:
title = record['name']
title += " (" + unicode(record['start_year']) + ")"
title += " - "
break
selector.setWindowTitle( title + "Select Issue")
selector.setModal( True )
selector.exec_()
if selector.result():
#we should now have a volume ID
self.issue_number = selector.issue_number
self.accept()
return
def selectByID( self ):
for r in range(0, self.twList.rowCount()):
volume_id, b = self.twList.item( r, 0 ).data( QtCore.Qt.UserRole ).toInt()
if (volume_id == self.volume_id):
self.twList.selectRow( r )
break
def performQuery( self, refresh=False ):
self.progdialog = QtGui.QProgressDialog("Searching Online", "Cancel", 0, 100, self)
self.progdialog.setWindowTitle( "Online Search" )
self.progdialog.canceled.connect( self.searchCanceled )
self.progdialog.setModal(True)
self.search_thread = SearchThread( self.series_name, refresh )
self.search_thread.searchComplete.connect( self.searchComplete )
self.search_thread.progressUpdate.connect( self.searchProgressUpdate )
self.search_thread.start()
#QtCore.QCoreApplication.processEvents()
self.progdialog.exec_()
def searchCanceled( self ):
print "query cancelled"
self.search_thread.searchComplete.disconnect( self.searchComplete )
self.search_thread.progressUpdate.disconnect( self.searchProgressUpdate )
self.progdialog.canceled.disconnect( self.searchCanceled )
self.progdialog.reject()
QtCore.QTimer.singleShot(200, self.closeMe)
def closeMe( self ):
print "closeme"
self.reject()
def searchProgressUpdate( self , current, total ):
self.progdialog.setMaximum(total)
self.progdialog.setValue(current)
def searchComplete( self ):
self.progdialog.accept()
if self.search_thread.cv_error:
if self.search_thread.error_code == ComicVineTalkerException.RateLimit:
QtGui.QMessageBox.critical(self, self.tr("Comic Vine Error"), ComicVineTalker.getRateLimitMessage())
else:
QtGui.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to ComicVine to search for series!"))
return
self.cv_search_results = self.search_thread.cv_search_results
self.updateButtons()
self.twList.setSortingEnabled(False)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
row = 0
for record in self.cv_search_results:
self.twList.insertRow(row)
item_text = record['name']
item = QtGui.QTableWidgetItem( item_text )
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole ,record['id'])
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = str(record['start_year'])
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
item_text = record['count_of_issues']
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData(QtCore.Qt.DisplayRole, record['count_of_issues'])
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if record['publisher'] is not None:
item_text = record['publisher']['name']
item.setData( QtCore.Qt.ToolTipRole, item_text )
item = QtGui.QTableWidgetItem(item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems( 2 , QtCore.Qt.DescendingOrder )
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
if len( self.cv_search_results ) == 0:
QtCore.QCoreApplication.processEvents()
QtGui.QMessageBox.information(self,"Search Result", "No matches found!")
if self.immediate_autoselect and len( self.cv_search_results ) > 0:
# defer the immediate autoselect so this dialog has time to pop up
QtCore.QCoreApplication.processEvents()
QtCore.QTimer.singleShot(10, self.doImmediateAutoselect)
def doImmediateAutoselect( self ):
self.immediate_autoselect = False
self.autoSelect()
def cellDoubleClicked( self, r, c ):
self.showIssues()
def currentItemChanged( self, curr, prev ):
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.volume_id, b = self.twList.item( curr.row(), 0 ).data( QtCore.Qt.UserRole ).toInt()
# list selection was changed, update the info on the volume
for record in self.cv_search_results:
if record['id'] == self.volume_id:
if record['description'] is None:
self.teDetails.setText ( "" )
else:
self.teDetails.setText ( record['description'] )
self.imageWidget.setURL( record['image']['super_url'] )
break

View File

@ -911,6 +911,12 @@ class PostProcessor(object):
stat = ' [' + str(i) + '/' + str(len(manual_list)) + ']'
self.Process_next(comicid, issueid, issuenumOG, ml, stat)
dupthis = None
else:
pass
#check if duplicate dump folder is enabled and if so move duplicate file in there for manual intervention.
#if mylar.DUPLICATE_DUMP:
# if dupthis == 'dupe_src':
#
logger.info(module + ' Manual post-processing completed for ' + str(i) + ' issues.')
return
else:

View File

@ -133,9 +133,7 @@ SEARCH_DELAY = 1
COMICVINE_API = None
DEFAULT_CVAPI = '583939a3df0a25fc4e8b7a29934a13078002dc27'
CVAPI_COUNT = 0
CVAPI_TIME = None
CVAPI_MAX = 400
CVAPI_RATE = 2
CHECK_GITHUB = False
CHECK_GITHUB_ON_STARTUP = False
@ -408,7 +406,7 @@ def check_setting_str(config, cfg_name, item_name, def_val, log=True):
def initialize():
with INIT_LOCK:
global __INITIALIZED__, DBCHOICE, DBUSER, DBPASS, DBNAME, COMICVINE_API, DEFAULT_CVAPI, CVAPI_COUNT, CVAPI_TIME, CVAPI_MAX, FULL_PATH, PROG_DIR, VERBOSE, DAEMON, UPCOMING_SNATCHED, COMICSORT, DATA_DIR, CONFIG_FILE, CFG, CONFIG_VERSION, LOG_DIR, CACHE_DIR, MAX_LOGSIZE, LOGVERBOSE, OLDCONFIG_VERSION, OS_DETECT, \
global __INITIALIZED__, DBCHOICE, DBUSER, DBPASS, DBNAME, COMICVINE_API, DEFAULT_CVAPI, CVAPI_RATE, FULL_PATH, PROG_DIR, VERBOSE, DAEMON, UPCOMING_SNATCHED, COMICSORT, DATA_DIR, CONFIG_FILE, CFG, CONFIG_VERSION, LOG_DIR, CACHE_DIR, MAX_LOGSIZE, LOGVERBOSE, OLDCONFIG_VERSION, OS_DETECT, \
queue, LOCAL_IP, EXT_IP, HTTP_PORT, HTTP_HOST, HTTP_USERNAME, HTTP_PASSWORD, HTTP_ROOT, ENABLE_HTTPS, HTTPS_CERT, HTTPS_KEY, HTTPS_FORCE_ON, HOST_RETURN, API_ENABLED, API_KEY, DOWNLOAD_APIKEY, LAUNCH_BROWSER, GIT_PATH, SAFESTART, AUTO_UPDATE, \
CURRENT_VERSION, LATEST_VERSION, CHECK_GITHUB, CHECK_GITHUB_ON_STARTUP, CHECK_GITHUB_INTERVAL, GIT_USER, GIT_BRANCH, USER_AGENT, DESTINATION_DIR, MULTIPLE_DEST_DIRS, CREATE_FOLDERS, DELETE_REMOVE_DIR, \
DOWNLOAD_DIR, USENET_RETENTION, SEARCH_INTERVAL, NZB_STARTUP_SEARCH, INTERFACE, DUPECONSTRAINT, AUTOWANT_ALL, AUTOWANT_UPCOMING, ZERO_LEVEL, ZERO_LEVEL_N, COMIC_COVER_LOCAL, HIGHCOUNT, \
@ -463,9 +461,7 @@ def initialize():
COMICVINE_API = check_setting_str(CFG, 'General', 'comicvine_api', '')
if not COMICVINE_API:
COMICVINE_API = None
CVAPI_COUNT = check_setting_int(CFG, 'General', 'cvapi_count', 0)
CVAPI_TIME = check_setting_str(CFG, 'General', 'cvapi_time', '')
helpers.cvapi_check() #get the values logged in.
CVAPI_RATE = check_setting_int(CFG, 'General', 'cvapi_rate', 2)
HTTP_HOST = check_setting_str(CFG, 'General', 'http_host', '0.0.0.0')
HTTP_USERNAME = check_setting_str(CFG, 'General', 'http_username', '')
HTTP_PASSWORD = check_setting_str(CFG, 'General', 'http_password', '')
@ -1184,11 +1180,8 @@ def config_write():
new_config['General']['comicvine_api'] = COMICVINE_API
else:
new_config['General']['comicvine_api'] = COMICVINE_API.strip()
#write the current CV API time / count here so it's persistent through reboots/restarts.
#get the current values.
helpers.cvapi_check()
new_config['General']['cvapi_count'] = CVAPI_COUNT
new_config['General']['cvapi_time'] = CVAPI_TIME
new_config['General']['cvapi_rate'] = CVAPI_RATE
new_config['General']['http_port'] = HTTP_PORT
new_config['General']['http_host'] = HTTP_HOST
new_config['General']['http_username'] = HTTP_USERNAME
@ -1456,10 +1449,6 @@ def start():
helpers.latestdate_fix()
#start the ComicVine API Counter here.
logger.info('Initiating the ComicVine API Checker to report API hits every 5 minutes.')
#SCHED.add_interval_job(helpers.cvapi_check, minutes=5)
#initiate startup rss feeds for torrents/nzbs here...
if ENABLE_RSS:
#SCHED.add_interval_job(rsscheck.tehMain, minutes=int(RSS_CHECKINTERVAL))

View File

@ -16,7 +16,6 @@ from subprocess import CalledProcessError, check_output
import mylar
from mylar import logger
from mylar.helpers import cvapi_check
def run(dirName, nzbName=None, issueid=None, comversion=None, manual=None, filename=None, module=None):
@ -29,6 +28,10 @@ def run(dirName, nzbName=None, issueid=None, comversion=None, manual=None, filen
## Set the directory in which comictagger and other external commands are located - IMPORTANT - ##
# ( User may have to modify, depending on their setup, but these are some guesses for now )
# 2015-11-23: Recent CV API changes restrict the rate-limit to 1 api request / second.
# ComicTagger has to be included now with the install as a timer had to be added to allow for the 1/second rule.
# The below is pretty much outdated - so will force mylar to use cmtagger_path = mylar.PROG_DIR to force the use of the included lib.
if platform.system() == "Windows":
#if it's a source install.
sys_type = 'windows'
@ -319,7 +322,7 @@ def run(dirName, nzbName=None, issueid=None, comversion=None, manual=None, filen
comversion = '1'
comversion = re.sub('[^0-9]', '', comversion).strip()
cvers = 'volume=' + str(comversion)
tagoptions = ["-s", "--verbose", "-m", cvers]
tagoptions = ["-s", "-m", cvers] #"--verbose"
## check comictagger version - less than 1.15.beta - take your chances.
if sys_type == 'windows':
@ -396,9 +399,12 @@ def run(dirName, nzbName=None, issueid=None, comversion=None, manual=None, filen
logger.info(module + ' ' + tagdisp + ' meta-tagging processing started.')
#CV API Check here.
if mylar.CVAPI_COUNT == 0 or mylar.CVAPI_COUNT >= 200:
cvapi_check()
#new CV API restriction - one api request / second (redundant here).
#if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
# time.sleep(2)
#else:
# time.sleep(mylar.CVAPI_RATE)
if sys_type == 'windows':
currentScriptName = str(comictagger_cmd).decode("string_escape")
else:
@ -418,10 +424,6 @@ def run(dirName, nzbName=None, issueid=None, comversion=None, manual=None, filen
except OSError, e:
logger.warn(module + '[COMIC-TAGGER] Unable to run comictagger with the options provided: ' + str(script_cmd))
#increment CV API counter.
mylar.CVAPI_COUNT += 1
## Tag each CBZ, and move it back to original directory ##
#if use_cvapi == "True":
# if issueid is None:

View File

@ -23,7 +23,6 @@ import urllib2
import lib.feedparser
import mylar
import platform
from mylar.helpers import cvapi_check
from bs4 import BeautifulSoup as Soup
import httplib
@ -76,15 +75,14 @@ def pulldetails(comicid, type, issueid=None, offset=1, arclist=None, comicidlist
PULLURL = mylar.CVURL + 'volumes/?api_key=' + str(comicapi) + '&format=xml&filter=id:' + str(comicidlist) + '&field_list=name,id,start_year,publisher&offset=' + str(offset)
#logger.info('CV.PULLURL: ' + PULLURL)
#CV API Check here.
if mylar.CVAPI_COUNT == 0 or mylar.CVAPI_COUNT >= mylar.CVAPI_MAX:
chkit = cvapi_check()
if chkit == False:
return "apireached"
#new CV API restriction - one api request / second.
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
#download the file:
file = urllib2.urlopen(PULLURL)
#increment CV API counter.
mylar.CVAPI_COUNT += 1
#convert to string:
data = file.read()
#close file because we dont need it anymore:
@ -120,8 +118,6 @@ def getComic(comicid, type, issueid=None, arc=None, arcid=None, arclist=None, co
searched = pulldetails(id, 'issue', None, 0, islist)
if searched is None:
return False
elif searched == 'apireached':
return 'apireached'
totalResults = searched.getElementsByTagName('number_of_total_results')[0].firstChild.wholeText
logger.fdebug("there are " + str(totalResults) + " search results...")
if not totalResults:
@ -133,8 +129,6 @@ def getComic(comicid, type, issueid=None, arc=None, arcid=None, arclist=None, co
#new api - have to change to page # instead of offset count
offsetcount = countResults
searched = pulldetails(id, 'issue', None, offsetcount, islist)
if searched == 'apireached':
return 'apireached'
issuechoice, tmpdate = GetIssuesInfo(id, searched, arcid)
if tmpdate < firstdate:
firstdate = tmpdate
@ -148,26 +142,18 @@ def getComic(comicid, type, issueid=None, arc=None, arcid=None, arclist=None, co
elif type == 'comic':
dom = pulldetails(comicid, 'comic', None, 1)
if dom == 'apireached':
return 'apireached'
return GetComicInfo(comicid, dom)
elif type == 'firstissue':
dom = pulldetails(comicid, 'firstissue', issueid, 1)
if dom == 'apireached':
return 'apireached'
return GetFirstIssue(issueid, dom)
elif type == 'storyarc':
dom = pulldetails(arc, 'storyarc', None, 1)
if dom == 'apireached':
return 'apireached'
return GetComicInfo(issueid, dom)
elif type == 'comicyears':
#used by the story arc searcher when adding a given arc to poll each ComicID in order to populate the Series Year.
#this grabs each issue based on issueid, and then subsets the comicid for each to be used later.
#set the offset to 0, since we're doing a filter.
dom = pulldetails(arcid, 'comicyears', offset=0, comicidlist=comicidlist)
if dom == 'apireached':
return 'apireached'
return GetSeriesYears(dom)
def GetComicInfo(comicid, dom, safechk=None):

View File

@ -1261,44 +1261,6 @@ def havetotals(refreshit=None):
return comics
def cvapi_check(web=None):
import logger
#if web is None:
# logger.fdebug('[ComicVine API] ComicVine API Check Running...')
if mylar.CVAPI_TIME is None or mylar.CVAPI_TIME == '':
c_date = now()
c_obj_date = datetime.datetime.strptime(c_date, "%Y-%m-%d %H:%M:%S")
mylar.CVAPI_TIME = c_obj_date
else:
if isinstance(mylar.CVAPI_TIME, unicode):
c_obj_date = datetime.datetime.strptime(mylar.CVAPI_TIME, "%Y-%m-%d %H:%M:%S")
else:
c_obj_date = mylar.CVAPI_TIME
#if web is None: logger.fdebug('[ComicVine API] API Start Monitoring Time (~15mins): ' + str(mylar.CVAPI_TIME))
now_date = now()
n_date = datetime.datetime.strptime(now_date, "%Y-%m-%d %H:%M:%S")
#if web is None: logger.fdebug('[ComicVine API] Time now: ' + str(n_date))
absdiff = abs(n_date - c_obj_date)
mins = round(((absdiff.days * 24 * 60 * 60 + absdiff.seconds) / 60.0), 2)
apivalid = True
if mins < 15:
#if web is None: logger.info('[ComicVine API] Comicvine API count now at : ' + str(mylar.CVAPI_COUNT) + ' / ' + str(mylar.CVAPI_MAX) + ' in ' + str(mins) + ' minutes.')
if mylar.CVAPI_COUNT + 5 > mylar.CVAPI_MAX:
cvleft = 15 - mins
if web is None: logger.warn('[ComicVine API] You have already hit your API limit (' + str(mylar.CVAPI_MAX) + ' with ' + str(cvleft) + ' minutes left out of 15 minutes. Best be slowing down, cowboy.')
apivalid = False
elif mins > 15:
mylar.CVAPI_COUNT = 0
c_date = now()
mylar.CVAPI_TIME = datetime.datetime.strptime(c_date, "%Y-%m-%d %H:%M:%S")
#if web is None: logger.info('[ComicVine API] 15 minute API interval resetting [' + str(mylar.CVAPI_TIME) + ']. Resetting API count to : ' + str(mylar.CVAPI_COUNT))
if web is None:
return apivalid
else:
line = str(mylar.CVAPI_COUNT) + ' hits / ' + str(mins) + ' minutes'
return line
def filesafe(comic):
import unicodedata
u_comic = unicodedata.normalize('NFKD', comic).encode('ASCII', 'ignore').strip()
@ -1637,6 +1599,10 @@ def duplicate_filecheck(filename, ComicID=None, IssueID=None, StoryArcID=None):
return
#if it's a retry and the file was already snatched, the status is Snatched and won't hit the dupecheck.
#rtnval will be one of 3:
#'write' - write new file
#'dupe_file' - do not write new file as existing file is better quality
#'dupe_src' - write new file, as existing file is a lesser quality (dupe)
if dupchk['Status'] == 'Downloaded' or dupchk['Status'] == 'Archived':
try:
dupsize = dupchk['ComicSize']
@ -1657,9 +1623,10 @@ def duplicate_filecheck(filename, ComicID=None, IssueID=None, StoryArcID=None):
mylar.updater.dbUpdate(ComicIDList=cid, calledfrom='dupechk')
return duplicate_filecheck(filename, ComicID, IssueID, StoryArcID)
else:
rtnval = "dupe"
#not sure if this one is correct - should never actually get to this point.
rtnval = "dupe_file"
else:
rtnval = "dupe"
rtnval = "dupe_file"
else:
logger.info('[DUPECHECK] Existing file :' + dupchk['Location'] + ' has a filesize of : ' + str(dupsize) + ' bytes.')
@ -1670,8 +1637,8 @@ def duplicate_filecheck(filename, ComicID=None, IssueID=None, StoryArcID=None):
logger.info('[DUPECHECK] Existing filesize is 0 as I cannot locate the original entry.')
if dupchk['Status'] == 'Archived':
logger.info('[DUPECHECK] Assuming issue is Archived.')
rtnval = "dupe"
return
rtnval = "dupe_file"
return rtnval
else:
logger.info('[DUPECHECK] Assuming 0-byte file - this one is gonna get hammered.')
@ -1682,29 +1649,29 @@ def duplicate_filecheck(filename, ComicID=None, IssueID=None, StoryArcID=None):
if dupchk['Location'].endswith('.cbz'):
#keep dupechk['Location']
logger.info('[DUPECHECK-CBR PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining currently scanned in file : ' + dupchk['Location'])
rtnval = "dupe"
rtnval = "dupe_file"
else:
#keep filename
logger.info('[DUPECHECK-CBR PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining newly scanned in file : ' + filename)
rtnval = "write"
rtnval = "dupe_src"
elif 'cbz' in mylar.DUPECONSTRAINT:
if dupchk['Location'].endswith('.cbr'):
#keep dupchk['Location']
logger.info('[DUPECHECK-CBZ PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining currently scanned in filename : ' + dupchk['Location'])
rtnval = "dupe"
rtnval = "dupe_file"
else:
#keep filename
logger.info('[DUPECHECK-CBZ PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining newly scanned in filename : ' + filename)
rtnval = "write"
rtnval = "dupe_src"
if mylar.DUPECONSTRAINT == 'filesize':
if filesz <= int(dupsize) and int(dupsize) != 0:
logger.info('[DUPECHECK-FILESIZE PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining currently scanned in filename : ' + dupchk['Location'])
rtnval = "dupe"
rtnval = "dupe_file"
else:
logger.info('[DUPECHECK-FILESIZE PRIORITY] [#' + dupchk['Issue_Number'] + '] Retaining newly scanned in filename : ' + filename)
rtnval = "write"
rtnval = "dupe_src"
else:
logger.info('[DUPECHECK] Duplication detection returned no hits. This is not a duplicate of anything that I have scanned in as of yet.')

View File

@ -93,8 +93,6 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
# we need to lookup the info for the requested ComicID in full now
comic = cv.getComic(comicid, 'comic')
if comic == 'apireached':
return 'apireached'
logger.fdebug(comic)
if not comic:
@ -153,14 +151,10 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
#we'll defer this until later when we grab all the issues and then figure it out
logger.info('Uh-oh. I cannot find a Series Year for this series. I am going to try analyzing deeper.')
SeriesYear = cv.getComic(comicid, 'firstissue', comic['FirstIssueID'])
if SeriesYear == 'apireached':
return 'apireached'
if SeriesYear == '0000':
logger.info('Ok - I could not find a Series Year at all. Loading in the issue data now and will figure out the Series Year.')
CV_NoYearGiven = "yes"
issued = cv.getComic(comicid, 'issue')
if issued == 'apireached':
return 'apireached'
SeriesYear = issued['firstdate'][:4]
else:
SeriesYear = gcdinfo['SeriesYear']
@ -221,8 +215,6 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
#print "annualyear: " + str(annualval['AnnualYear'])
logger.fdebug('[IMPORTER-ANNUAL] - Annual Year:' + str(annualyear))
sresults, explicit = mb.findComic(annComicName, mode, issue=None, explicit='all')#,explicit=True)
if sresults == 'apireached':
return 'apireached'
type='comic'
if len(sresults) == 1:
@ -245,8 +237,6 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
num_res+=1 # need to manually increment since not a for-next loop
continue
issued = cv.getComic(issueid, 'issue')
if issued == 'apireached':
return 'apireached'
if len(issued) is None or len(issued) == 0:
logger.fdebug('[IMPORTER-ANNUAL] - Could not find any annual information...')
pass
@ -299,8 +289,6 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
elif len(sresults) == 0 or len(sresults) is None:
logger.fdebug('[IMPORTER-ANNUAL] - No results, removing the year from the agenda and re-querying.')
sresults, explicit = mb.findComic(annComicName, mode, issue=None)#, explicit=True)
if sresults == 'apireached':
return 'apireached'
if len(sresults) == 1:
sr = sresults[0]
logger.fdebug('[IMPORTER-ANNUAL] - ' + str(comicid) + ' found. Assuming it is part of the greater collection.')
@ -418,6 +406,12 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
#urllib.urlretrieve(str(thisci), str(coverfile))
#new CV API restriction - one api request / second.(probably unecessary here, but it doesn't hurt)
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
try:
cimage = re.sub('[\+]', '%20', comic['ComicImage'])
request = urllib2.Request(cimage)#, headers={'Content-Type': 'application/x-www-form-urlencoded'})
@ -440,6 +434,12 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
except Exception, e:
logger.warn('[%s] Error fetching data using : %s' % (e, comic['ComicImage']))
logger.info('Attempting to use alternate image size to get cover.')
#new CV API restriction - one api request / second.
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
try:
cimage = re.sub('[\+]', '%20', comic['ComicImageALT'])
request = urllib2.Request(cimage)
@ -516,8 +516,6 @@ def addComictoDB(comicid, mismatch=None, pullupd=None, imported=None, ogcname=No
if issued is None:
logger.warn('Unable to retrieve data from ComicVine. Get your own API key already!')
return
elif issued == 'apireached':
return 'apireached'
logger.info('Sucessfully retrieved issue details for ' + comic['ComicName'])
#move to own function so can call independently to only refresh issue data
@ -805,7 +803,12 @@ def GCDimport(gcomicid, pullupd=None, imported=None, ogcname=None):
coverfile = os.path.join(mylar.CACHE_DIR, str(gcomicid) + ".jpg")
#try:
#new CV API restriction - one api request / second.
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
urllib.urlretrieve(str(ComicImage), str(coverfile))
try:
with open(str(coverfile)) as f:
@ -1077,8 +1080,6 @@ def manualAnnual(manual_comicid, comicname, comicyear, comicid):
issueid = manual_comicid
logger.fdebug(str(issueid) + ' added to series list as an Annual')
sr = cv.getComic(manual_comicid, 'comic')
if sr == 'apireached':
return 'apireached'
logger.info('Attempting to integrate ' + sr['ComicName'] + ' (' + str(issueid) + ') to the existing series of ' + comicname + '(' + str(comicyear) + ')')
if len(sr) is None or len(sr) == 0:
logger.fdebug('Could not find any information on the series indicated : ' + str(manual_comicid))
@ -1091,8 +1092,6 @@ def manualAnnual(manual_comicid, comicname, comicyear, comicid):
else:
noissues = sr['ComicIssues']
logger.fdebug('there are ' + str(noissues) + ' annuals within this series.')
if issued == 'apireached':
return 'apireached'
while (n < int(noissues)):
try:
firstval = issued['issuechoice'][n]
@ -1151,8 +1150,6 @@ def updateissuedata(comicid, comicname=None, issued=None, comicIssues=None, call
if comic is None:
logger.warn('Error retrieving from ComicVine - either the site is down or you are not using your own CV API key')
return
elif comic == 'apireached':
return 'apireached'
if comicIssues is None:
comicIssues = comic['ComicIssues']
if SeriesYear is None:
@ -1164,8 +1161,6 @@ def updateissuedata(comicid, comicname=None, issued=None, comicIssues=None, call
if issued is None:
logger.warn('Error retrieving from ComicVine - either the site is down or you are not using your own CV API key')
return
elif issued == 'apireached':
return 'apireached'
# poll against annuals here - to make sure annuals are uptodate.
weeklyissue_check = annual_check(comicname, SeriesYear, comicid, issuetype, issuechk, weeklyissue_check)
@ -1478,8 +1473,6 @@ def annual_check(ComicName, SeriesYear, comicid, issuetype, issuechk, weeklyissu
annualyear = SeriesYear # no matter what, the year won't be less than this.
logger.fdebug('[IMPORTER-ANNUAL] - Annual Year:' + str(annualyear))
sresults, explicit = mb.findComic(annComicName, mode, issue=None, explicit='all')#,explicit=True)
if sresults == 'apireached':
return 'apireached'
type='comic'
if len(sresults) == 1:
@ -1502,9 +1495,7 @@ def annual_check(ComicName, SeriesYear, comicid, issuetype, issuechk, weeklyissu
num_res+=1 # need to manually increment since not a for-next loop
continue
issued = cv.getComic(issueid, 'issue')
if issued == 'apireached':
return 'apireached'
elif len(issued) is None or len(issued) == 0:
if len(issued) is None or len(issued) == 0:
logger.fdebug('[IMPORTER-ANNUAL] - Could not find any annual information...')
pass
else:
@ -1569,8 +1560,6 @@ def annual_check(ComicName, SeriesYear, comicid, issuetype, issuechk, weeklyissu
elif len(sresults) == 0 or len(sresults) is None:
logger.fdebug('[IMPORTER-ANNUAL] - No results, removing the year from the agenda and re-querying.')
sresults, explicit = mb.findComic(annComicName, mode, issue=None)#, explicit=True)
if sresults == 'apireached':
return 'apireached'
if len(sresults) == 1:
sr = sresults[0]
logger.fdebug('[IMPORTER-ANNUAL] - ' + str(comicid) + ' found. Assuming it is part of the greater collection.')

View File

@ -24,7 +24,7 @@ from xml.dom.minidom import parseString, Element
import mylar
from mylar import logger, db, cv
from mylar.helpers import multikeysort, replace_all, cleanName, cvapi_check, listLibrary
from mylar.helpers import multikeysort, replace_all, cleanName, listLibrary
import httplib
mb_lock = threading.Lock()
@ -60,12 +60,14 @@ def pullsearch(comicapi, comicquery, offset, explicit, type):
u_comicquery = re.sub("%20AND%20", "%20", u_comicquery)
PULLURL = mylar.CVURL + str(type) + 's?api_key=' + str(comicapi) + '&filter=name:' + u_comicquery + '&field_list=id,name,start_year,site_detail_url,count_of_issues,image,publisher,deck,description&format=xml&offset=' + str(offset) # 2012/22/02 - CVAPI flipped back to offset instead of page
#all these imports are standard on most modern python implementations
#CV API Check here.
#logger.info('MB.PULLURL:' + PULLURL)
if mylar.CVAPI_COUNT == 0 or mylar.CVAPI_COUNT >= mylar.CVAPI_MAX:
chkit = cvapi_check()
if chkit == False:
return 'apireached'
#new CV API restriction - one api request / second.
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
#download the file:
try:
file = urllib2.urlopen(PULLURL)
@ -73,8 +75,6 @@ def pullsearch(comicapi, comicquery, offset, explicit, type):
logger.error('err : ' + str(err))
logger.error("There was a major problem retrieving data from ComicVine - on their end. You'll have to try again later most likely.")
return
#increment CV API counter.
mylar.CVAPI_COUNT +=1
#convert to string:
data = file.read()
#close file because we dont need it anymore:
@ -132,8 +132,6 @@ def findComic(name, mode, issue, limityear=None, explicit=None, type=None):
searched = pullsearch(comicapi, comicquery, 0, explicit, type)
if searched is None:
return False
elif searched == 'apireached':
return 'apireached'
totalResults = searched.getElementsByTagName('number_of_total_results')[0].firstChild.wholeText
logger.fdebug("there are " + str(totalResults) + " search results...")
if not totalResults:
@ -154,8 +152,6 @@ def findComic(name, mode, issue, limityear=None, explicit=None, type=None):
offsetcount = countResults
searched = pullsearch(comicapi, comicquery, offsetcount, explicit, type)
if searched == 'apireached':
return 'apireached'
comicResults = searched.getElementsByTagName(type) #('volume')
body = ''
n = 0
@ -361,10 +357,13 @@ def storyarcinfo(xmlid):
#respawn to the exact id for the story arc and count the # of issues present.
ARCPULL_URL = mylar.CVURL + 'story_arc/4045-' + str(xmlid) + '/?api_key=' + str(comicapi) + '&field_list=issues,name,first_appeared_in_issue,deck,image&format=xml&offset=0'
logger.fdebug('arcpull_url:' + str(ARCPULL_URL))
if mylar.CVAPI_COUNT == 0 or mylar.CVAPI_COUNT >= mylar.CVAPI_MAX:
chkit = cvapi_check()
if chkit == False:
return 'apireached'
#new CV API restriction - one api request / second.
if mylar.CVAPI_RATE is None or mylar.CVAPI_RATE < 2:
time.sleep(2)
else:
time.sleep(mylar.CVAPI_RATE)
try:
file = urllib2.urlopen(ARCPULL_URL)
except urllib2.HTTPError, err:
@ -372,7 +371,6 @@ def storyarcinfo(xmlid):
logger.error('There was a major problem retrieving data from ComicVine - on their end.')
return
mylar.CVAPI_COUNT +=1
arcdata = file.read()
file.close()
arcdom = parseString(arcdata)

View File

@ -202,7 +202,7 @@ def search_init(ComicName, IssueNumber, ComicYear, SeriesYear, Publisher, IssueD
if len(prov_order) == 1:
tmp_prov_count = 1
else:
tmp_prov_count = len(prov_order) - 1
tmp_prov_count = len(prov_order)
while (prov_count < tmp_prov_count): #len(prov_order) -1):
send_prov_count = tmp_prov_count - prov_count

Some files were not shown because too many files have changed in this diff Show More