├── activity-diagram.dia ├── activity-diagram.png ├── README.md ├── UserTests.py ├── DefTests.py ├── LICENSE └── Shotwell_event2folder.py /activity-diagram.dia: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablo33/Shotwell-event2folder/HEAD/activity-diagram.dia -------------------------------------------------------------------------------- /activity-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablo33/Shotwell-event2folder/HEAD/activity-diagram.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Shotwell-event2folder 2 | Shotwell event to folder structure 3 | 4 | This python3 script reorders your shotwell library files into an event-oriented folder structure. 5 | 6 | Shotwell can get photo-files from the file-system, assigning each file to an event. But these files will remain on its source storage or once imported to shotwell they remain always at the same place. 7 | This script will rearrange your files in your file system based on shotwell events. 8 | 9 | The script will process shotwell DB and commit the changes. 10 | and even more, optionally: 11 | - it can automatically rename filenames by starting with its date identifier (YYYYMMDD_hhmmss filename.jpg) 12 | - it can automatically send the most recent images to a defined folder. 13 | - it can get the name of the filename and insert it into Shotwell Database as Title. 14 | - it can process and recompress diverse movie files into .mov files. 15 | 16 | **Dependencies:** 17 | 18 | Python3, GExiv2 from Gi.repository, ffmpeg 19 | 20 | This script has been tesded with Shotwell 0.18 to 0.32 (shipped with ubuntu 14.10 to 24.04LTS). 21 | 22 | Installing dependencies: 23 | I'm sure that python3 comes with your linux distribution. 24 | You can install the packages from the command line: GExiv2 is the "GObject-based wrapper around the Exiv2 library - introspection data" 25 | 26 | sudo apt-get install gir1.2-gexiv2-0.10 ffmpeg 27 | 28 | 29 | **Usage:** 30 | Just launch the script from command line, it will create a config file, you can edit it to fit by your needings and then run it again. 31 | 32 | python3 Shotwell_event2folder.py 33 | 34 | 35 | 36 | See wiki page for further information. 37 | [https://github.com/pablo33/Shotwell-event2folder/wiki](https://github.com/pablo33/Shotwell-event2folder/wiki) 38 | -------------------------------------------------------------------------------- /UserTests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' This script moves Test Packs to a folder on your hard disk. 4 | and will perform a test upon a database created from Shotwell application. 5 | ''' 6 | 7 | # Module import 8 | import unittest 9 | import os, shutil #sys, logging, datetime, time, re 10 | from glob import glob 11 | 12 | # Tools 13 | def addchilddirectory(directorio): 14 | """ Returns a list of child directories 15 | 16 | Usage: addchilddirectory(directory with absolute path)""" 17 | paraañadir = [] 18 | ficheros = os.listdir(directorio) 19 | for a in ficheros: 20 | item = os.path.join(directorio, a) 21 | if os.path.isdir(item): 22 | paraañadir.append(item) 23 | return paraañadir 24 | 25 | def lsdirectorytree(directory): 26 | """ Returns a list of a directory and its child directories 27 | 28 | usage: 29 | lsdirectorytree ("start directory") 30 | By default, user's home directory 31 | 32 | Own start directory is also returned as result 33 | """ 34 | #init list to start, own start directory is included 35 | dirlist = [directory] 36 | #setting the first scan 37 | moredirectories = dirlist 38 | while len (moredirectories) != 0: 39 | newdirectories = moredirectories 40 | moredirectories = list () 41 | for element in newdirectories: 42 | toadd = addchilddirectory(element) 43 | moredirectories += toadd 44 | dirlist += moredirectories 45 | return dirlist 46 | 47 | 48 | def SetTestPack (namepack): 49 | namepack = os.path.join(dyntestfolder, namepack) 50 | # delete old contents in test(n) folder 51 | if os.path.isdir (namepack): 52 | shutil.rmtree (namepack) 53 | 54 | # decompress pack 55 | os.system ('unzip %s.zip -d %s'%(namepack, dyntestfolder)) 56 | 57 | # copying test database 58 | if os.path.isfile (DBpath): 59 | os.remove (DBpath) 60 | shutil.move (os.path.join(namepack,'photo.db'), DBpath) 61 | 62 | # copying user test config file 63 | if os.path.isfile (usercfgpath): 64 | os.remove (usercfgpath) 65 | shutil.move (os.path.join(namepack,'Shotevent2folder_cfg.py'), usercfgpath) 66 | 67 | 68 | def FetchFileSet (path): 69 | ''' Fetchs a file set of files and folders''' 70 | listree = lsdirectorytree (path) 71 | fileset = set() 72 | for x in listree: 73 | contentlist = (glob( os.path.join (x,'*'))) 74 | for a in contentlist: 75 | fileset.add (a) 76 | return fileset 77 | 78 | 79 | homedir = os.getenv('HOME') 80 | #dyntestfolder = os.path.join(homedir,'git/Shotwell-event2folder/TESTS') 81 | DBpath = os.path.join(homedir,'.local/share/shotwell/data/photo.db') 82 | usercfgpath = os.path.join(homedir,'.Shotwell-event2folder','Shotevent2folder_cfg.py') 83 | dyntestfolder = 'TESTS' 84 | 85 | 86 | 87 | class TestPack1 (unittest.TestCase): 88 | ''' processing TestPack1 alloptions active''' 89 | 90 | reftest = 'Test1' 91 | testfolder = os.path.join (dyntestfolder,reftest) 92 | 93 | 94 | def test_alloptionsactivated (self): 95 | ''' insertdates in filenames 96 | clearfolders 97 | send pictures to a more recent directory 98 | import titles form filenames (you had to see it on shotwell DB) 99 | interttitles in files (you had to see it on files) 100 | ''' 101 | 102 | SetTestPack (self.reftest) 103 | os.system ('python3 Shotwell_event2folder.py') 104 | 105 | known_values = set ([ 106 | 'TESTS/Test1/DefinitiveStorage', 107 | 'TESTS/Test1/DefinitiveStorage/2016', 108 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-02', 109 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-02/20160602_120000-IMG-20160602-WA0000.jpg', 110 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-03', 111 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-03/20160603_124317-34651951377.jpg', 112 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-04', 113 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-04/20160604_102253-IMG_0468.JPG', 114 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-06', 115 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-06/20160606_195355.jpg', 116 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-06/20160606_195434.jpg', 117 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-09', 118 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-09/20160609_120000-IMG-20160609-WA0001.jpg', 119 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-13', 120 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-13/20160613_210952-IMG_20160613_210951.jpg', 121 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18', 122 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_104842 4841.jpg', 123 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_113029 img_1263.jpg', 124 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_113121 img_1264.jpg', 125 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_113649 img_1265.jpg', 126 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_113657 3657.jpg', 127 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_125709-IMG_20160618_125709.jpg', 128 | 'TESTS/Test1/DefinitiveStorage/2016/2016-06-18/20160618_131326-IMG_20160618_131325.jpg', 129 | 'TESTS/Test1/DefinitiveStorage/no_event', 130 | 'TESTS/Test1/DefinitiveStorage/no_event/Screenshot from 2016-06-28 19-56-56.png', 131 | 'TESTS/Test1/Lastphotospath', 132 | 'TESTS/Test1/Lastphotospath/2016', 133 | 'TESTS/Test1/Lastphotospath/2016/2016-06-18', 134 | 'TESTS/Test1/Lastphotospath/2016/2016-06-18/20160618_153207 mvi_1293.mov', 135 | 'TESTS/Test1/Lastphotospath/2016/2016-06-18/20160618_224303 4302.jpg', 136 | 'TESTS/Test1/Lastphotospath/2016/2016-06-22', 137 | 'TESTS/Test1/Lastphotospath/2016/2016-06-22/20160622_141158-VID_20160622_141158.3gp', 138 | 'TESTS/Test1/Lastphotospath/2016/2016-06-22/20160622_141203 No date on filename.jpg', 139 | ]) 140 | 141 | result = FetchFileSet (self.testfolder) 142 | self.assertEqual(known_values, result) 143 | 144 | 145 | 146 | 147 | if __name__ == '__main__': 148 | unittest.main() -------------------------------------------------------------------------------- /DefTests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Test Configuration 3 | import unittest 4 | import Shotwell_event2folder 5 | import datetime 6 | import os 7 | 8 | 9 | 10 | #####TESTS######## 11 | 12 | TM = Shotwell_event2folder 13 | 14 | class itemcheck_text_values (unittest.TestCase): 15 | '''testing itemcheck function''' 16 | def test_emptystring (self): 17 | ''' an empty string returns another empty string''' 18 | self.assertEqual (TM.itemcheck(""),"") 19 | 20 | def test_itemcheck (self): 21 | ''' only text are addmitted as input ''' 22 | sample_bad_values = (True, False, None, 33, 3.5) 23 | for values in sample_bad_values: 24 | self.assertRaises (TM.NotStringError, TM.itemcheck, values) 25 | 26 | def test_malformed_paths (self): 27 | ''' malformed path as inputs are ommited and raises an error ''' 28 | malformed_values = ("///","/home//") 29 | for inputstring in malformed_values: 30 | self.assertRaises (TM.MalformedPathError, TM.itemcheck, inputstring) 31 | 32 | 33 | class Nextfilenumber_test (unittest.TestCase): 34 | """ test for Nextfilenumber function """ 35 | known_values = ( 36 | ("file.jpg", "file(0).jpg"), 37 | ("file1.jpg", "file1(0).jpg"), 38 | ("file(0).jpg", "file(1).jpg"), 39 | ("file(222).jpg", "file(223).jpg"), 40 | ("file33", "file33(0)"), 41 | ("file(33)", "file(34)"), 42 | ("file(-1)", "file(-1)(0)"), 43 | ("file.","file(0)."), 44 | ("file(10).", "file(11)."), 45 | ("file(X).jpg", "file(X)(0).jpg"), 46 | ) 47 | def test_known_input (self): 48 | for inputfile, outputfile in self.known_values: 49 | result = TM.Nextfilenumber (inputfile) 50 | self.assertEqual (outputfile, result) 51 | def test_mad_values (self): 52 | self.assertRaises (TM.EmptyStringError, TM.Nextfilenumber, "") 53 | pass 54 | 55 | 56 | class extracttitle_test (unittest.TestCase): 57 | """ Extracts a title from a filename (string) """ 58 | known_values = ( 59 | ("2015-02-23 10:22:30 my title" , "my title"), 60 | ("2015-02-23 10:22:30 123456 --- my title" , "my title"), 61 | ("2015-02-23 123456 --- my title" , "my title"), 62 | ("2015-02-23123456 --- my title" , "my title"), 63 | ("2015-12 --- my title XXX" , "my title XXX"), 64 | ("2015-12 #&#$%---#03 my 3rd title XXX" , "my 3rd title XXX"), 65 | ("2015-12 #&#$%---#03 my title 33" , "my title"), 66 | ("2015-12 #&#$%---#03 my title 33" , "my title"), 67 | ('img', None), 68 | ('jpg', None), 69 | ('foto', None), 70 | ('image', None), 71 | ('PhoTo', None), 72 | ('PHOTO', None), 73 | ('picture', None), 74 | ('scan', None), 75 | ('12345', None), 76 | ('00', None), # titles made by numbers are not allowed 77 | ('--00', None), # titles made by numbers are not allowed 78 | ('Wa2244 my title', 'my title'), # titles made by numbers are not allowed 79 | ('20101213-230005Wa2244 my title', 'my title'), # titles made by numbers are not allowed 80 | ('MVI_1234 my title', 'my title'), 81 | ('my title - MVI_1234 ', 'my title'), 82 | ('123456789 - my title - MVI_1234 ', 'my title'), 83 | ) 84 | 85 | def test_known_input (self): 86 | for inputfile, outputfile in self.known_values: 87 | result = TM.extracttitle (inputfile) 88 | self.assertEqual (outputfile, result) 89 | 90 | 91 | class Thumbfilepath (unittest.TestCase): 92 | """ Given a ID, it returns a full-filepath to its thumbnails """ 93 | thumbsfolder = os.getenv('HOME')+'/.cache/shotwell/thumbs/' 94 | 95 | known_values = ( 96 | (1, ('thumb0000000000000001',thumbsfolder+'thumbs128/thumb0000000000000001.jpg',thumbsfolder+'thumbs360/thumb0000000000000001.jpg')), 97 | (2, ('thumb0000000000000002',thumbsfolder+'thumbs128/thumb0000000000000002.jpg',thumbsfolder+'thumbs360/thumb0000000000000002.jpg')), 98 | (100, ('thumb0000000000000064',thumbsfolder+'thumbs128/thumb0000000000000064.jpg',thumbsfolder+'thumbs360/thumb0000000000000064.jpg')), 99 | (1555, ('thumb0000000000000613',thumbsfolder+'thumbs128/thumb0000000000000613.jpg',thumbsfolder+'thumbs360/thumb0000000000000613.jpg')), 100 | ) 101 | 102 | def test_known_input (self): 103 | for inputvalue, expectedvalue in self.known_values: 104 | result = TM.Thumbfilepath (inputvalue) 105 | self.assertEqual (expectedvalue, result) 106 | 107 | def test_Thumbfilepathexeptions (self): 108 | ''' only numbers are addmitted as input ''' 109 | sample_bad_values = ("58", "33", True) 110 | for values in sample_bad_values: 111 | self.assertRaises (TM.NotIntegerError, TM.Thumbfilepath, values) 112 | 113 | sample_bad_values = (-1, 0, -32323) 114 | for values in sample_bad_values: 115 | self.assertRaises (TM.OutOfRangeError, TM.Thumbfilepath, values) 116 | 117 | 118 | class NoTAlloChReplace_test (unittest.TestCase): 119 | """ Given a string, replace by an undescore this set of characters 120 | / \ : * ? " < > | 121 | Empty strings returns empty strings 122 | """ 123 | known_values = ( 124 | ('myfile:name','myfile_name'), 125 | ('myfile:name.jpg','myfile_name.jpg'), 126 | ('',''), 127 | ('*myfile*name>','_myfile_name_'), 128 | ('myfilenam|e','myfilenam_e'), 129 | (' 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /Shotwell_event2folder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Git Repository at: https://github.com/pablo33/Shotwell-event2folder 4 | # License: GNU General Public License v2.0 5 | __author__ = "pablo33" 6 | __version__ = "1.3.2" 7 | 8 | 9 | import sqlite3, os, sys, shutil, logging, re, time, pickle 10 | from hashlib import md5 11 | 12 | from datetime import datetime 13 | import gi # in use to avoid Gi warning 14 | gi.require_version('GExiv2', '0.10') # in use to avoid Gi warning 15 | from gi.repository import GExiv2 # for metadata management. Dependencies: gir1.2-gexiv2 & python-gobject 16 | from subprocess import check_output # Checks if shotwell is active or not 17 | 18 | 19 | # ------- Class Exceptions --------- 20 | class OutOfRangeError(ValueError): 21 | pass 22 | class NotIntegerError(ValueError): 23 | pass 24 | class NotStringError(ValueError): 25 | pass 26 | class MalformedPathError(ValueError): 27 | pass 28 | class EmptyStringError(ValueError): 29 | pass 30 | 31 | 32 | # ------- Set Environment --------- 33 | gi.require_version('GExiv2', '0.10') # just to avoid Gi warning 34 | 35 | # ------- Set Variables --------- 36 | UserHomePath = os.getenv('HOME') 37 | DBpath = os.path.join(UserHomePath,".local/share/shotwell/data/photo.db") # Path where Shotwell DB is expected to be. 38 | Th128path = os.path.join(UserHomePath,".cache/shotwell/thumbs/thumbs128") # Path where thumbnails are stored. 39 | Th360path = os.path.join(UserHomePath,".cache/shotwell/thumbs/thumbs360") # Path where thumbnails are stored. 40 | LastExec = None 41 | 42 | # -------- Global Vars ---------- 43 | mintepoch = '1800' # In order to discard low year values, this is the lowest year. // fetched later by user configuration. 44 | 45 | 46 | monthsdict = { 47 | "01" : ("enero", "ene", "juanuary", "jan"), 48 | "02" : ("febrero", "feb", "february"), 49 | "03" : ("marzo", "mar", "march"), 50 | "04" : ("abril", "abr", "april", "apr"), 51 | "05" : ("mayo", "may","may"), 52 | "06" : ("junio", "jun", "june"), 53 | "07" : ("julio", "jul", "july"), 54 | "08" : ("agosto", "ago", "agost"), 55 | "09" : ("septiembre", "sep", "set","september"), 56 | "10" : ("octubre", "oct", "october"), 57 | "11" : ("noviembre", "nov", "november"), 58 | "12" : ("diciembre", "dic", "december", "dec"), 59 | } # Months word dict. 60 | 61 | # ------ utils -------- 62 | def itemcheck (pointer:str)->str: 63 | """ Returns what kind of a pointer is.""" 64 | if type (pointer) is not str: 65 | raise NotStringError ('Bad input, it must be a string') 66 | if pointer.find ("//") != -1 : 67 | raise MalformedPathError ('Malformed Path, it has double slashes') 68 | 69 | if os.path.isfile (pointer): 70 | return 'file' 71 | if os.path.isdir (pointer): 72 | return 'folder' 73 | if os.path.islink (pointer): 74 | return 'link' 75 | return "" 76 | 77 | def Nextfilenumber (dest:str)->str: 78 | """ Returns the next filename counter as filename(nnn).ext. 79 | 80 | input: /path/to/filename.ext 81 | output: /path/to/filename(n).ext 82 | """ 83 | if dest == "": 84 | raise EmptyStringError ('empty strings as input are not allowed') 85 | filename = os.path.basename (dest) 86 | extension = os.path.splitext (dest)[1] 87 | # extract secuence 88 | expr = r'\(\d{1,}\)'+extension 89 | mo = re.search (expr, filename) 90 | try: 91 | grupo = mo.group() 92 | except: 93 | # print ("No final counter expression was found in %s. Counter is set to 0" % dest) 94 | counter = 0 95 | cut = len (extension) 96 | else: 97 | # print ("Filename has a final counter expression. (n).extension ") 98 | cut = len (mo.group()) 99 | countergroup = (re.search (r'\d{1,}', grupo)) 100 | counter = int (countergroup.group()) + 1 101 | if cut == 0 : 102 | newfilename = os.path.join( os.path.dirname(dest), filename + "(" + str(counter) + ")" + extension) 103 | else: 104 | newfilename = os.path.join( os.path.dirname(dest), filename [0:-cut] + "(" + str(counter) + ")" + extension) 105 | return newfilename 106 | 107 | def NoTAlloChReplace (myfilename:str)->str: 108 | """ Eliminates not allowed characters in filenames. 109 | 110 | This function gets a string and replace a set of characters by a underscore. 111 | It is intended to clean filenames and add compatibility with Windows and OSx file systems 112 | """ 113 | chars = r'/\:*?"<>|' 114 | for i in chars: 115 | myfilename = myfilename.replace(i, '_') 116 | return myfilename 117 | 118 | def gsettingsget (schema, key, data_type): 119 | """ Retrieves software configuration values from dconf. 120 | 121 | Gets a key from dconfig using gsettings binary. 122 | check_output returns a bites string. You have to decode this string to the expected key data. 123 | """ 124 | value = check_output(['gsettings','get', schema, key]) 125 | 126 | if data_type == 'bool': 127 | value = eval(value.decode().strip().capitalize()) 128 | return value 129 | 130 | # Functions 131 | def extracttitle (photofilename:str)->str: 132 | title = photofilename 133 | 134 | # Discarding fulldate identifiers 135 | sep = r'[-._: ]' 136 | for expr in [r'[12]\d{3}%(sp)s?[01]\d%(sp)s?[0-3]\d%(sp)s?[012]\d%(sp)s?[0-5]\d%(sp)s?[0-5]\d' %{'sp':sep}, 137 | r'[12]\d{3}%(sp)s?[01]\d%(sp)s?[0-3]\d' %{'sp':sep}, 138 | r'[Ww][Aa]\d{4}', 139 | r'[12]\d{3}%(sp)s?[01]\d' %{'sp':sep}, 140 | r'MVI%(sp)s\d{4}' %{'sp':sep} ]: 141 | while True: 142 | mo = re.search (expr, title) 143 | try: 144 | mo.group() 145 | except: 146 | logging.debug (f"Fulldate expression was not found in {title}") 147 | break 148 | else: 149 | logging.debug ("Filename has a full date expression. Discarding this data on title.") 150 | title = title.replace(mo.group(),"") 151 | 152 | # Replacing starting simbols & numbers 153 | expr = r'[0-9 ]?[-_#.%$& ]?[0-9 ]?' 154 | while True: 155 | mo = re.search (expr, title) 156 | try: 157 | mo.group() 158 | except: 159 | break 160 | else: 161 | if title.startswith (mo.group()): 162 | logging.debug ("Discarding starting sybols and spaces.") 163 | title = title [len (mo.group()): ] 164 | if mo.group() == "": 165 | break 166 | else: 167 | break 168 | 169 | # Replacing ending simbols & numbers 170 | if len(title) > 0: 171 | while title[-1] in '-_#1234567890 ': 172 | title = title[:-1] 173 | if len(title) == 0: 174 | break 175 | 176 | 177 | # Replacing standalone known series 178 | if title.lower() in ['img', 'jpg', 'foto', 'image', 'photo', 'scan', 'picture']: 179 | logging.debug ("Discarding known standalone serie:" + title.lower()) 180 | title = "" 181 | 182 | # Assigning a None value if title is empty 183 | if title == "": 184 | title = None 185 | logging.debug ("The title for this entry will be: " + str(title)) 186 | return title 187 | 188 | def filemove (origin:str, dest:str)->str: 189 | """ Moves a file from source to a destination. 190 | 191 | It implements Nextfilenaumber function to avoid overwriting files. 192 | """ 193 | if itemcheck (origin) != 'file': 194 | return None 195 | while itemcheck (dest) != "" : 196 | infomsg = "File already exists at destination, assigning a new name." 197 | dest = Nextfilenumber (dest) 198 | logging.debug (infomsg + " >> " + dest) 199 | 200 | if dummy == False: 201 | if itemcheck (os.path.dirname(dest)) == '': 202 | os.makedirs (os.path.dirname(dest)) 203 | shutil.move (origin, dest) 204 | logging.debug (f"\tfile has been moved. {dummymsg}") 205 | return dest 206 | 207 | def Thumbfilepath (ID:int,Tablename='PhotoTable')->tuple: 208 | """ This function returns the full-filepath of the thumbnails given an id 209 | Thumbs are composed by the ID of the file filled with Zeroes at a length of 16. 210 | ID are expressed in Hex. This is the mask: 211 | # thumb000000000000000f 212 | Paths are expressed as follows (ID = 10) 213 | ~/.cache/shotwell/thumbs/thumbs128/thumb000000000000000a.jpg 214 | ~/.cache/shotwell/thumbs/thumbs360/thumb000000000000000a.jpg 215 | 216 | Paths to thumbnails folders are global vars. (Th128path, Th360path) 217 | """ 218 | lead = 'thumb' 219 | if Tablename == 'VideoTable': 220 | lead = 'video-' 221 | 222 | if type(ID) is not int: 223 | raise NotIntegerError(ID) 224 | if ID < 1 : 225 | raise OutOfRangeError(ID) 226 | 227 | source_id = lead + '%016x'%ID 228 | 229 | Path128 = os.path.join(Th128path,source_id + ".jpg") 230 | Path360 = os.path.join(Th360path,source_id + ".jpg") 231 | return (source_id,Path128,Path360) 232 | 233 | def Deletethumb (ID:int): 234 | """ Given an ID, deletes its Thumbnails. 235 | """ 236 | for f in Thumbfilepath(ID)[2:3]: 237 | if itemcheck(f) == 'file': 238 | if dummy == False: 239 | os.remove (f) 240 | infomsg = ('Thumbfile for ID ({}) has been removed'.format(ID)) 241 | print (infomsg) 242 | logging.debug (infomsg) 243 | 244 | def get_pid (app:str)->list: 245 | """ Get the PID of a running application. 246 | 247 | Returns None if the aplication is not running, or 248 | returns application PID if the aplication is running 249 | """ 250 | try: 251 | pids = check_output(["pidof", app ]) 252 | except: 253 | logging.debug(f'no {app} process is currently running') 254 | return None 255 | pidlist = pids.split() 256 | la = lambda x : int(x) 257 | pidlist = list (map (la , pidlist)) 258 | return pidlist 259 | 260 | def getappstatus (apps:list)->bool: 261 | """ Gets the status of a list of applications. 262 | 263 | Given a list of names's process, it checks if there is any instance running 264 | DefTest >> OK 265 | """ 266 | state = False 267 | for entry in apps: 268 | if get_pid (entry) != None: 269 | state = True 270 | break 271 | return state 272 | 273 | def addtoconfigfile (linetoadd:str): 274 | print ("adding a new parameter to the user config file: {}".format(linetoadd.split()[0])) 275 | f = open(userfileconfig,"a") 276 | f.write ("\n" + linetoadd) 277 | f.close() 278 | 279 | def Changes ()->bool: 280 | """ Check if ShotwellDatabase has modifications since last execution. 281 | 282 | The last date of execution is stored in a file at user's configuration folder. 283 | The file is created only in daemonmode. 284 | 285 | It returns True in case it has changed 286 | It returns False in other case 287 | """ 288 | global LastExec, lastExecFile 289 | shotwellDBstatDate = datetime.fromtimestamp(os.path.getmtime (DBpath)) 290 | if LastExec == None or itemcheck (lastExecFile) != 'file': 291 | logging.info (f'Shotwell DB time: {shotwellDBstatDate}') 292 | if itemcheck (lastExecFile) == 'file': 293 | f = open (lastExecFile, 'rb') 294 | LastExec = pickle.load (f) 295 | f.close () 296 | logging.info (f'LastExec from pickle: {LastExec}') 297 | else: 298 | LastExec = datetime.now() 299 | logging.info (f'Initializing pickle: {LastExec}') 300 | return True 301 | if shotwellDBstatDate > LastExec: 302 | return True 303 | logging.debug ( 'shotwellDB has no changes:') 304 | logging.debug (f'shotwellDBstatDate: {shotwellDBstatDate}') 305 | logging.debug (f' LastExec: {LastExec}') 306 | return False 307 | 308 | class Progresspercent: 309 | """ Show the progression of an activity in percentage. 310 | 311 | it is swhon on the same line 312 | """ 313 | def __init__ (self, maxValue:int, title = '', showpartial=True): 314 | if title != '': 315 | self.title = f" {title} :" # Name of the activity 316 | else: 317 | self.title = " " 318 | self.maxValue = maxValue 319 | self.partial = showpartial 320 | 321 | def showprogress (self, p:int, valuetext = "")->str: 322 | """ Shows the progress in percentage vía stdout, and returns its value again.""" 323 | progressvalue = (p / self.maxValue) 324 | progresspercent = f"{progressvalue:.2%}" 325 | if self.partial == True: 326 | progresspartial = f"({p:6}/{self.maxValue:<6})" 327 | else: 328 | progresspartial = '' 329 | progresstext = f"{self.title}{valuetext}{progresspartial}{progresspercent}" 330 | #sys.stdout.write (progresstext + chr(8)*len(progresstext)) 331 | sys.stdout.write (progresstext + chr(8)*len(progresstext)) 332 | if p == self.maxValue: 333 | sys.stdout.write('\n') 334 | sys.stdout.flush() 335 | return progresspercent 336 | 337 | def md5hash (filepath:str)->str: 338 | """ Md5 hash of a file. 339 | 340 | Given a fullpath to a file, it will return the md5 hashstring 341 | it reads in bytes mode and it will load the full file in memory 342 | """ 343 | hasher = md5() 344 | with open(filepath, 'rb') as afile: 345 | buf = afile.read() 346 | hasher.update(buf) 347 | return (hasher.hexdigest()) 348 | 349 | def enclosedyearfinder (string:str)->str: 350 | """ Searchs for a year string. 351 | 352 | Returns a string representing a year(numbers), or None if it doesn't 353 | """ 354 | if string.isnumeric(): 355 | return string 356 | return None 357 | 358 | def enclosedmonthfinder (string:str)->str: 359 | """ Finds and returns a month number or month name. 360 | 361 | Give a string, returns a string if it is a month number, 362 | otherwise it returns None, 363 | """ 364 | if len (string) == 2 and string.isnumeric (): 365 | if int(string) in range(1,13): 366 | logging.debug( f'found possible month in {string}') 367 | return string 368 | for element in monthsdict: 369 | if string.lower() in monthsdict[element]: 370 | return element 371 | return None 372 | 373 | def encloseddayfinder (string:str)->str: 374 | """ Finds and returns a day number as string. 375 | 376 | Give a string, returns a string if it is a month number, 377 | otherwise it returns None, 378 | """ 379 | if len(string) == 2 and string.isnumeric(): 380 | if int(string) in range(1,32): 381 | logging.debug( f'found possible day in {string}') 382 | return string 383 | return None 384 | 385 | def yearmonthfinder (string:str)->tuple: 386 | """ Finds and returns a year and moth numbers. 387 | 388 | Given a string, returns a tuple of numeric year-month if it is found, 389 | otherwise returns None . 390 | """ 391 | expr = r".*(?P[12]\d{3})[-_ /:.]?(?P[01]?\d).*" 392 | mo = re.search(expr, string) 393 | try: 394 | mo.group() 395 | except: 396 | pass 397 | else: 398 | num_month = int(mo.group('month')) 399 | if num_month in range (1,13) : 400 | fnyear = mo.group ('year') 401 | fnmonth = f'{num_month:02}' 402 | return fnyear, fnmonth 403 | return None, None 404 | 405 | def yearmonthdayfinder (string:str)->tuple: 406 | """ Finds and returns a year, month and day numbers. 407 | 408 | Given a string, returns a combo of numeric year-month-day if it is found, 409 | otherwise returns None. 410 | """ 411 | 412 | expr = r"(?P[12]\d{3})[-_ /:.]?(?P[01]?\d)[-_ /:.]?(?P[0-3]?\d)" 413 | mo = re.search(expr, string) 414 | try: 415 | mo.group() 416 | except: 417 | pass 418 | else: 419 | fnyear, num_month, num_day = mo.group ('year'), int(mo.group('month')), int(mo.group('day')) 420 | if 0 < num_month < 13 and 0 < num_day < 32: 421 | fnmonth = f'{num_month:02}' 422 | fnday = f'{num_day:02}' 423 | return fnyear, fnmonth, fnday 424 | return None, None, None 425 | 426 | def fulldatefinder (string:str)->tuple: 427 | """ Finds and returns a fulldate identifier packed on a tuple. 428 | 429 | Given a string, returns a combo of numeric YYYY-MM-DD-hh-mm-ss True if a full-date-identifier 430 | is found, otherwise returns None 431 | """ 432 | start = False 433 | sep = r'[-_ :.]' 434 | expr = r'(?P[12]\d{3})%(sep)s?(?P[01]?\d)%(sep)s?(?P[0-3]?\d)%(sep)s?(?P[012]\d)%(sep)s?(?P[0-5]\d)%(sep)s?(?P[0-5]\d)' %{'sep':'[-_ .:]'} 435 | mo = re.search (expr, string) 436 | try: 437 | mo.group() 438 | except: 439 | logging.debug(f"expression {expr} Not found in {string}") 440 | pass 441 | else: 442 | num_month, num_day = int(mo.group ('month')), int(mo.group ('day')) 443 | year = mo.group ('year') 444 | month = f'{num_month:02}' 445 | day = f'{num_day:02}' 446 | hour = mo.group ('hour') 447 | minute = mo.group ('min') 448 | sec = mo.group ('sec') 449 | if mo.start() == 0 : 450 | start = True 451 | return year, month, day, hour, minute, sec, start 452 | return None, None, None, None, None, None, None 453 | 454 | def serieserial (string:str)->tuple: 455 | """ Finds and return a serie and serial identifiers. 456 | 457 | Given a filename string, it returns serie and serial number (tuple) 458 | otherwise it returns None 459 | """ 460 | 461 | sep = r'[-_ ]' 462 | seriallist = ['WA','IMG','PICT','MVI','img'] 463 | #seriallist = seriallist + seriallist.lower() for 464 | for key in seriallist : 465 | expr = r'(?P%s%s?)(?P[0-9]{4})'%(key,sep) 466 | 467 | mo = re.search (expr, string) 468 | try: 469 | mo.group() 470 | except: 471 | logging.debug( f"expression {expr} Not found in {string}") 472 | continue 473 | else: 474 | logging.debug( f"expression {expr} found in {string}") 475 | imserie = mo.group ('se') 476 | imserial = mo.group ('sn') 477 | logging.debug( f'Item serie and serial number ({string}): {imserie} {imserial}') 478 | return imserie, imserial 479 | return None, None 480 | 481 | def findeventname(Abranch:str)->str: 482 | """ Finds and returns an event name from a path string. 483 | """ 484 | # /YYYY-MM XeventnameX/ 485 | exprlst = [ 486 | r"/[12]\d{3}[-_ ]?[01]\d ?(?P.*)/", 487 | r"[12]\d{3}[-_ ]?[01]\d[-_ ]?[0-3]\d ?(?P.*)/", 488 | ] 489 | 490 | # /YYYY-MM-DD XeventnameX/ 491 | eventname = '' 492 | for expr in exprlst: 493 | mo = re.search(expr, Abranch) 494 | try: 495 | mo.group() 496 | except: 497 | pass 498 | else: 499 | eventname = mo.group('XeventnameX') 500 | return eventname 501 | 502 | def mediainfo (abspath:str, assignstat:bool)->tuple: 503 | """ Finds and returns creation date of media, and if it was assigned from stat. 504 | """ 505 | # Global dependent variables: 506 | # mintepoch # In order to discard low year values, this is the lowest year. 507 | 508 | #1) Retrieve basic info from the file 509 | logging.debug( f'## item: {abspath}') 510 | filename, fileext = os.path.splitext(os.path.basename (abspath)) 511 | Statdate = datetime.fromtimestamp(os.path.getmtime (abspath)) 512 | filebytes = os.path.getsize(abspath) # logging.debug ('fileTepoch (from Stat): '.ljust( logjustif ) + str(fileTepoch)) 513 | fnDateTimeOriginal = None # From start we assume a no date found on the file path 514 | decideflag = None 515 | TimeOriginal = None 516 | 517 | #2) Fetch date identificators form imagepath, serie and serial number if any. 518 | 519 | # Try to find some date structure in folder paths. (abspath) 520 | r''' Fetch dates from folder structure, this prevents losing information if exif metadata 521 | doesn't exist. Metada can be lost if you modify files with software. It is also usefull 522 | if you move video files (wich doesn't have exif metadata) among cloud services. 523 | Pej. you can store a folder structure in your PC client dropbox, and you'll lose your "stat" date, 524 | but you can always recover it from file name/path. 525 | Structures: 526 | Years: 527 | one of the path-folder starts as a year number with four numbers 528 | [12]\d{3} YYYY 529 | Months: 530 | one of the path folders is a month numbers 531 | Combos: 532 | one of the path folders starts with YYYY-MM 533 | 534 | Full date: 535 | there is a full-date structure on the path. 536 | 2015-01-04 | 2015_01_04 | 2015:01:04 | 2015 01 04 537 | 538 | The day, hour-minutes and seconds asigned are 01, 12:00:00 + image serial number (in seconds) for each image to preserve an order. 539 | ''' 540 | ## Cutting main tree from fullpaths. 541 | pathlevels = os.path.dirname (abspath).split ('/') 542 | # Removig not wanted slashes 543 | if '' in pathlevels: 544 | pathlevels.remove('') 545 | logging.debug (f'Found directories levels: {str(pathlevels)}') 546 | # Starting variables. From start, we assume that there is no date at all. 547 | fnyear = None 548 | fnmonth = None 549 | fnday = '01' 550 | fnhour = '12' 551 | fnmin = '00' 552 | fnsec = '00' 553 | for word in pathlevels: 554 | # C1.1 (/year/) 555 | yearfound = enclosedyearfinder (word) 556 | if yearfound != None: 557 | if mintepoch < yearfound < '2038': 558 | fnyear = yearfound 559 | continue 560 | 561 | # C1.2 (/month/) 562 | monthfound = enclosedmonthfinder (word) 563 | if monthfound != None: 564 | fnmonth = monthfound 565 | continue 566 | 567 | # C1.3 (/day/): 568 | dayfound = encloseddayfinder (word) 569 | if dayfound != None: 570 | fnday = dayfound 571 | continue 572 | 573 | # C2.1 (Year-month) 574 | yearfound, monthfound = yearmonthfinder (word) 575 | if yearfound != None: 576 | if mintepoch < yearfound < "2038": 577 | fnyear = yearfound 578 | fnmonth = monthfound 579 | logging.debug(f'month and day found in C2.1 {fnyear}-{fnmonth}') 580 | 581 | # C3.1: (Year-month-day) 582 | yearfound, monthfound, dayfound = yearmonthdayfinder (word) 583 | if yearfound != None: 584 | if mintepoch < yearfound < "2038": 585 | fnyear = yearfound 586 | fnmonth = monthfound 587 | fnday = dayfound 588 | 589 | 590 | # C4: YYYY-MM-DD in filename 591 | yearfound, monthfound, dayfound = yearmonthdayfinder( filename) 592 | if yearfound != None: 593 | if mintepoch < yearfound < "2038": 594 | fnyear = yearfound 595 | fnmonth = monthfound 596 | fnday = dayfound 597 | logging.debug( f'month and day found in C4 {fnyear}-{fnmonth}-{fnday}') 598 | 599 | # C3.2 (Year-month in filename) 600 | if fnyear == None and fnmonth == None: 601 | yearfound, monthfound = yearmonthfinder ( filename) 602 | if yearfound != None: 603 | if mintepoch < yearfound < "2038": 604 | fnyear = yearfound 605 | fnmonth = monthfound 606 | logging.debug( f'month and day found in C3.2 {fnyear}-{fnmonth}') 607 | 608 | # C5: YYYYMMDD-HHMMSS in filename and find a starting full-date identifier 609 | Imdatestart = False # Flag to inform a starting full-date-identifier at the start of the file. 610 | foundtuple = fulldatefinder( filename) 611 | 612 | if foundtuple[0] != None: 613 | if mintepoch < foundtuple[0] < "2038": 614 | fnyear = foundtuple[0] 615 | fnmonth = foundtuple[1] 616 | fnday = foundtuple[2] 617 | fnhour = foundtuple[3] 618 | fnmin = foundtuple[4] 619 | fnsec = foundtuple[5] 620 | logging.debug( f'found full date identifier in {filename}') 621 | #if mo.start() == 0 : 622 | if foundtuple[6] == True: 623 | logging.debug( f'filename starts with a full date identifier: {filename}' ) 624 | Imdatestart = True # True means that filename starts with full-date serial in its name (item will not add any date in his filename again) 625 | 626 | 627 | # setting creation date retrieved from filepath 628 | if fnyear != None and fnmonth != None: 629 | textdate = '{}:{}:{} {}:{}:{}'.format( fnyear, fnmonth, fnday, fnhour, fnmin, fnsec) 630 | logging.debug( f'This date have been retrieved from the file-path-name: {textdate}') 631 | fnDateTimeOriginal = datetime.strptime( textdate, '%Y:%m:%d %H:%M:%S') 632 | 633 | 634 | # Fetch Serial number from filename 635 | imserie, imserial = serieserial( filename) 636 | 637 | # Set Creation Date extracted from filename/path 638 | if fnDateTimeOriginal != None : 639 | TimeOriginal = fnDateTimeOriginal 640 | decideflag = 'Filepath' 641 | 642 | elif assignstat: 643 | # Set Creation Date from stat file. 644 | TimeOriginal = Statdate 645 | decideflag = 'Stat' 646 | 647 | if decideflag != None: 648 | logging.debug( f'\tImage Creation date has been set from {decideflag}, ({str(TimeOriginal)}): ') 649 | 650 | if TimeOriginal == None: 651 | logging.debug ( "\tCan't guess Image date of Creation" ) 652 | TimeOriginalEpoch = None 653 | else: 654 | TimeOriginalEpoch = int( datetime.timestamp(TimeOriginal)) 655 | 656 | #return filename, fileext, filebytes, Imdatestart, fnDateTimeOriginal, Statdate, TimeOriginal, decideflag, imserie, imserial 657 | 658 | return TimeOriginalEpoch, decideflag 659 | 660 | def add_date_metadate (imagepath:str,TimeEpoch:str): 661 | """ Adds a date to the metadata of an image file. 662 | """ 663 | metadata = GExiv2.Metadata(imagepath) 664 | metadata.set_date_time (datetime.fromtimestamp(TimeEpoch)) 665 | metadata.save_file() 666 | logging.debug ( '\twrited metadata to image file.') 667 | logging.debug ( f'\t{imagepath}') 668 | return 669 | 670 | class find_my_file(): 671 | """ Class to find a file by its filename, it also can check its md5 hash 672 | 673 | Once found, it can returns its relative path, 674 | """ 675 | def __init__ (self, mainpath:str, filename:str, filemd5:str=None): 676 | self.mainpath = mainpath 677 | self.filename = filename 678 | self.filemd5 = filemd5 679 | self.foundpath = None 680 | self.md5match = None 681 | self.is_found = None 682 | self.searchfile() 683 | 684 | def searchfile (self)->str: 685 | """ Searches for the file in the mainpath. 686 | 687 | It returns the fullpath if found, or None if not found. 688 | """ 689 | for dirpath, dirnames, filenames in os.walk (self.mainpath): 690 | if self.filename in filenames: 691 | fullpath = os.path.join (dirpath, self.filename) 692 | self.foundpath = fullpath 693 | self.is_found = True 694 | print (f'File found: {fullpath}') 695 | print (f'Checking/calculating md5 hash....') 696 | calcmd5 = md5hash (fullpath) 697 | if self.filemd5 != None: 698 | if calcmd5 == self.filemd5: 699 | self.md5match = True 700 | logging.debug (f'File found with matching md5: {fullpath}') 701 | else: 702 | logging.debug (f'File found but md5 does not match, assigning new md5hash: {fullpath}') 703 | self.md5match = False 704 | self.md5 = calcmd5 705 | return fullpath 706 | self.is_found = False 707 | logging.debug (f'File not found: {self.filename} in {self.mainpath}') 708 | return None 709 | 710 | 711 | if __name__ == '__main__': 712 | 713 | # Load user config: 714 | # Getting user folder to place log files.... 715 | appuserpath= os.path.join (UserHomePath,".Shotwell-event2folder") 716 | userfileconfig = os.path.join (appuserpath,"Shotevent2folder_cfg.py") 717 | lastExecFile = os.path.join (appuserpath,".LastExec.dump") 718 | if itemcheck( appuserpath) != "folder": 719 | os.makedirs( appuserpath) 720 | 721 | if itemcheck( userfileconfig) == "file": 722 | print ("Loading user configuration....") 723 | sys.path.append( appuserpath) 724 | import Shotevent2folder_cfg 725 | else: 726 | print (f"\nThere isn't an user config file: {userfileconfig}") 727 | # Create a new config file 728 | f = open( userfileconfig,"w") 729 | f.write( '# Shotwell-event2folder Config file.\n# This is a python file. Be careful and see the sintaxt.\n\n') 730 | f.close() 731 | print( "\nYour user config file has been created at:", userfileconfig) 732 | Shotevent2folder_cfg = None 733 | 734 | abort = False 735 | 736 | # Getting variables from user's config file and/or updating it. 737 | Default_Config_options = ( 738 | ('librarymainpath', "f'{UserHomePath}/Pictures'", '# Main path where your imeges are or you want them to be.'), 739 | ('importtitlefromfilenames','False', '# Get a title from the filename and set it as title in the database. It only imports titles if the photo title at Database is empty.'), 740 | ('inserttitlesinfiles', 'False', '# Insert titles in files as metadata, you can insert or update your files with the database titles. If importtitlefromfilenames is True, and the title\'s in database is empty, it will set this retrieved title in both file, and database.'), 741 | ('insertdateinfilename', 'False', '# Filenames will be renamed with starting with a full-date expression.'), 742 | ('flat_tree', 'False', '# Place all events on a single folder, and not by years.'), 743 | ('assignstat', 'False', '# on autodate routine, assign a date from file creation (stat) in case a no valid date were found.'), 744 | ('autodate', 'False', '# When True, it tries to auto-date _no date event_ photos. It will retrieve dates from filenames and add them to an existing event. A new event is created in case no event is found.'), 745 | ('mintepoch', '1998', '# Minimun year, in order to fetch years from filenames.'), 746 | ('clearfolders', 'True' , '# Delete empty folders.'), 747 | ('librarymostrecentpath', "f'{UserHomePath}/Pictures/mostrecent'", '# Path to send the most recent pictures. You can set this path synced with Dropbox pej.'), 748 | ('mostrecentkbs', '0', '# Max amount of Kbs to send to the most recent pictures path as destination. Set 0 if you do not want to send any pictures there. (2_000_000_000 is 2Gb)'), 749 | ('morerecent_stars', '-1', '# use values from -1 to 5 . Filter pictures or videos by rating to send to the more recent pictures path as destination. use -1 to move all files or ignore this option (default).'), 750 | ('conv_mov', 'False','# Convert movies with ffmpeg to shrink their size'), 751 | ('conv_bitrate_kbs', '1200','# Movies under this average bitrate will not be processed'), 752 | ('conv_flag', "''",'# Only convert .mov videos wich ends on this string. leave an empty string to convert all videos.'), 753 | ('conv_extension', "'MOV'", '# Filter video conversion to this kind of movies, leave an empty string to convert all file formats.'), 754 | ('fix_missingvideos', 'False','# Fix missing videos in Shotwell database by scanning the library path for video files not present in the database.'), 755 | ('daemonmode', 'False','# It keeps the script running and process Shotwell DataBase if it has changes since last execution.'), 756 | ('sleepseconds', '120','# Number of seconds to sleep, until another check in daemon mode.'), 757 | ('dummy', 'False', '# Dummy mode. True will not perform any changes to DB or File structure.'), 758 | ('logging_level', "'INFO'", '# Logging level. It can be DEBUG, INFO, WARNING, ERROR, CRITICAL.'), 759 | ) 760 | 761 | retrievedvalues = dict () 762 | for option in Default_Config_options: 763 | try: 764 | retrievedvalues[option[0]] = eval( f'Shotevent2folder_cfg.{option[0]}') 765 | except AttributeError: 766 | addtoconfigfile ('{} = {} {}'.format(*option)) 767 | abort = True 768 | 769 | if abort: 770 | print ("Your user config file has been updated with new options:", userfileconfig, '\n') 771 | print ("Default values have been assigned, please customize by yourself before run this software again.\n") 772 | print ("This software will attempt to open your configuration file with a text editor (gedit).") 773 | input ("Press a key.") 774 | os.system ("gedit " + userfileconfig) 775 | exit() 776 | 777 | librarymainpath = retrievedvalues ['librarymainpath'] 778 | dummy = retrievedvalues ['dummy'] 779 | insertdateinfilename = retrievedvalues ['insertdateinfilename'] 780 | clearfolders = retrievedvalues ['clearfolders'] 781 | librarymostrecentpath = retrievedvalues ['librarymostrecentpath'] 782 | mostrecentkbs = retrievedvalues ['mostrecentkbs'] 783 | morerecent_stars = retrievedvalues ['morerecent_stars'] 784 | importtitlefromfilenames = retrievedvalues ['importtitlefromfilenames'] 785 | inserttitlesinfiles = retrievedvalues ['inserttitlesinfiles'] 786 | daemonmode = retrievedvalues ['daemonmode'] 787 | sleepseconds = retrievedvalues ['sleepseconds'] 788 | conv_mov = retrievedvalues ['conv_mov'] 789 | conv_bitrate_kbs = retrievedvalues ['conv_bitrate_kbs'] 790 | conv_flag = retrievedvalues ['conv_flag'] 791 | conv_extension = retrievedvalues ['conv_extension'] 792 | autodate = retrievedvalues ['autodate'] 793 | assignstat = retrievedvalues ['assignstat'] 794 | mintepoch = retrievedvalues ['mintepoch'] 795 | flat_tree = retrievedvalues ['flat_tree'] 796 | fix_missingvideos = retrievedvalues ['fix_missingvideos'] 797 | logging_level = retrievedvalues ['logging_level'] 798 | 799 | # Fetched from Shotwell's configuration 800 | commit_metadata = gsettingsget('org.yorba.shotwell.preferences.files','commit-metadata','bool') 801 | 802 | 803 | # =============================== 804 | # The logging module. 805 | # =============================== 806 | initialloginlevel = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL 807 | logpath = './' 808 | logging_file = os.path.join(logpath, 'Shotwell_event2folder.log') 809 | 810 | 811 | # Getting current date and time 812 | now = datetime.now() 813 | today = "/".join( [str(now.day), str(now.month), str(now.year)]) 814 | tohour = ":".join( [str(now.hour), str(now.minute)]) 815 | 816 | print( "Loginlevel:", initialloginlevel) 817 | logging.basicConfig( 818 | level = initialloginlevel, 819 | format = '%(asctime)s : %(levelname)s : %(message)s', 820 | filename = logging_file, 821 | filemode = 'w' # a = add 822 | ) 823 | print( "Logging to:", logging_file) 824 | 825 | # Check inconsistences. 826 | errmsgs = [] 827 | 828 | # --morerecent_stars 829 | if type(morerecent_stars) != int: 830 | errmsgs.append( "\n morerecent_stars at configuration file is not an integer. Must be from -1 to 5. (use -1 to move all files)") 831 | logging.critical( "morerecent_stars is not a integer") 832 | elif morerecent_stars not in range(-1,6): 833 | errmsgs.append( "\n morerecent_stars at configuration out of range. Must be from -1 to 5. (use -1 to move all files)") 834 | logging.critical( f"morerecent_stars out of range. actual value: {morerecent_stars}") 835 | 836 | # --conv_mov 837 | if type(conv_mov) != bool: 838 | errmsgs.append( "\n conv_mov at configuration file must be True or False.") 839 | logging.critical( "conv_mov value is not boolean.") 840 | else: 841 | # --conv_bitrate_kbs 842 | if conv_mov: 843 | if type (conv_bitrate_kbs) != int: 844 | errmsgs.append( "\n conv_bitrate_kbs at configuration file is not an integer and it should be greater than 800.") 845 | logging.critical( "conv_bitrate_kbs is not a integer") 846 | # --conv_flag 847 | if type (conv_flag) != str: 848 | errmsgs.append ("\n conv_flag at configuration file is not an string. It marks the video file to be converted, a good choice is (conv).") 849 | logging.critical ("conv_flag is not a string") 850 | elif conv_flag in ('_c','_f'): 851 | errmsgs.append ("\n conv_flag can't get this two values: _c or _f. A file ending in _c means a converted video, and _f means a failed conversion. Please choose other values.") 852 | logging.critical ("conv_flag is using Predefined values") 853 | # --conv_extension 854 | if conv_extension == '': 855 | conv_extension_q = '%' 856 | else: 857 | conv_extension_q = conv_extension 858 | 859 | # --autodate 860 | if type(autodate) != bool: 861 | errmsgs.append ("\n autodate at configuration file must be True or False.") 862 | logging.critical ("autodate value is not boolean.") 863 | else: 864 | if autodate: 865 | # --assignstat 866 | if type(assignstat) != bool: 867 | errmsgs.append ("\n assignstat at configuration file must be True or False.") 868 | logging.critical ("assignstat value is not boolean.") 869 | # --mintepoch 870 | if type(mintepoch) != int: 871 | errmsgs.append ("\n mintepoch at configuration file is not an integer. Default value is 1998.") 872 | logging.critical ("mintepoch is not a integer") 873 | else: 874 | mintepoch = str(mintepoch) 875 | 876 | # --flat_tree 877 | if type(flat_tree) != bool: 878 | errmsgs.append ("\n flat_tree at configuration file must be True or False.") 879 | logging.critical ("flat_tree value is not boolean.") 880 | 881 | # --fix_missingvideos 882 | if type(fix_missingvideos) != bool: 883 | errmsgs.append ("\n fix_missingvideos at configuration file must be True or False.") 884 | logging.critical ("fix_missingvideos value is not boolean.") 885 | 886 | if type(logging_level) != str: 887 | errmsgs.append( "\n logging_level at configuration file is not a string. Must be one of this: DEBUG, INFO, WARNING, ERROR, CRITICAL") 888 | logging.critical( "logging_level is not a string") 889 | elif logging_level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"): 890 | errmsgs.append( "\n logging_level at configuration out of range. Must be one of this: DEBUG, INFO, WARNING, ERROR, CRITICAL") 891 | logging.critical( f"logging_level out of range. actual value: {logging_level}") 892 | 893 | 894 | # exit if errors are econuntered 895 | if len (errmsgs) != 0 : 896 | for a in errmsgs: 897 | print (a) 898 | print ('\nplease revise your config file.','\n ....exitting',sep='\n') 899 | print ("This software will attempt to open your configuration file with a text editor (gedit).") 900 | os.system (f"gedit {userfileconfig}") 901 | exit() 902 | 903 | 904 | # Logging the actual config 905 | logging.info ('Running with this configuraton:') 906 | parametersdyct = { 907 | 'librarymainpath' : librarymainpath, 908 | 'dummy' : dummy, 909 | 'insertdateinfilename' : insertdateinfilename, 910 | 'clearfolders' : clearfolders, 911 | 'librarymostrecentpath' : librarymostrecentpath, 912 | 'mostrecentkbs' : mostrecentkbs, 913 | 'morerecent_stars' : morerecent_stars, 914 | 'importtitlefromfilenames': importtitlefromfilenames, 915 | 'daemonmode' : daemonmode, 916 | 'sleepseconds' : sleepseconds, 917 | 'conv_mov' : conv_mov, 918 | 'conv_bitrate_kbs' : conv_bitrate_kbs, 919 | 'conv_flag' : conv_flag, 920 | 'conv_extension' : conv_extension, 921 | 'autodate' : autodate, 922 | 'assignstat' : assignstat, 923 | 'commit_metadata' : commit_metadata, 924 | 'mintepoch' : mintepoch, 925 | 'flat_tree' : flat_tree, 926 | 'fix_missingvideos' : fix_missingvideos, 927 | 'logging_level' : logging_level, 928 | } 929 | 930 | 931 | for a in parametersdyct: 932 | logging.info (f'{" "*(30-len(a))}{a} = {parametersdyct[a]}') 933 | logging.info('') 934 | 935 | if initialloginlevel != logging_level: 936 | logging.getLogger().setLevel (logging_level) 937 | print( "Changed loginlevel to ", logging_level) 938 | 939 | 940 | # Inserting Escape chars for SQL querying 941 | conv_flag_q = conv_flag.replace ('/','//') 942 | conv_flag_q = conv_flag_q.replace ('%','/%') 943 | conv_flag_q = conv_flag_q.replace ('_','/_') 944 | 945 | # initializing global execution vars 946 | dummymsg = '' 947 | if dummy == True: 948 | dummymsg = '(dummy mode)' 949 | print ('Running in dummy mode.') 950 | 951 | # Checking if ffmpeg is at the system 952 | ffmpeg = False 953 | if conv_mov: 954 | if os.system('ffmpeg --help') != 0: 955 | print ('No ffmpeg tool is found. I will not able to process video files.') 956 | print ('You can install it by typing $sudo apt-get install ffmpeg.') 957 | else: 958 | print ('ffmpeg is present.') 959 | ffmpeg = True 960 | 961 | if daemonmode: 962 | print (f'Running in daemon mode. I will iterate every {sleepseconds} seconds.') 963 | 964 | while True: 965 | foldercollection = set () 966 | datelimit2move_exposure = datetime.now() 967 | 968 | # Check if Shotwell DB is present 969 | if itemcheck (DBpath) != "file": 970 | infomsg = f'Shotwell Database is not present, this script is intended to work on a Shotwell Database located at: {DBpath}' 971 | print (infomsg) ; logging.critical (infomsg) 972 | exit() 973 | 974 | countdown = 12 975 | execution = True 976 | 977 | if daemonmode: 978 | execution = Changes () 979 | 980 | # Check if Target folder is reachable. If not reachable, no execution possible. 981 | if itemcheck (librarymainpath) != 'folder': 982 | print ('\nWARNING: Library mainpath does not exist or is not reachable, revise configuration or make target folder available.') 983 | logging.warning ('Target folder is not reachable') 984 | execution = False 985 | 986 | # Check if shotwell process is alive. Cancels the execution if it is alive. 987 | if execution: 988 | execution = False 989 | for a in range (countdown,0,-1): 990 | if getappstatus (['shotwell']): 991 | print ('\nWARNING: Shotwell process is running, I will not run meanwhile Shotwell application is running.') 992 | logging.warning ('Shotwell process is running') 993 | if daemonmode: 994 | execution = False 995 | break 996 | print (f'{a} retries left to desist') 997 | time.sleep (10) 998 | else: 999 | execution = True 1000 | break 1001 | 1002 | if execution: 1003 | # Connecting to DB 1004 | dbconnection = sqlite3.connect (DBpath) 1005 | 1006 | __Schema__, __appversion__ = dbconnection.execute ("SELECT schema_version, app_version FROM versiontable").fetchone() 1007 | if 20 < __Schema__ < 24 : 1008 | print ("This utility may not work properly with a Shotwell DataBase Schema other than 20 to 24") 1009 | print ("DB schema 20~24 is used on Shotwell version 0.22.xx - 0.32.xx") 1010 | print (f"Actual DB Schema is {__Schema__}") 1011 | print (f"Actual Shotwell Version is {__appversion__}") 1012 | exit () 1013 | 1014 | # Autodate routine. 1015 | if autodate: 1016 | neweventsids = [] # I will try to add images to new created events. 1017 | logging.debug ('Starting autodate routine') 1018 | deltaHours = 8 # Gap to find an existent event for the images. 1019 | deltatime = int(deltaHours*60*60/2) 1020 | 1021 | dbnoeventcursor = dbconnection.cursor() 1022 | dbnoeventcursor.execute ("SELECT id,filename,timestamp,'PhotoTable',file_format,event_id FROM PhotoTable WHERE exposure_time = 0 and flags != 4 UNION SELECT id,filename,timestamp,'VideoTable',null,event_id FROM VideoTable WHERE exposure_time = 0 and flags != 4") 1023 | for entry in dbnoeventcursor: 1024 | logging.debug( f'Procesing no event_entry: {entry}') 1025 | Id, Filepath, Timestamp, Table, File_Format, Event_id = entry 1026 | eventID = Event_id 1027 | if itemcheck (Filepath) != 'file': 1028 | logging.warning ( f'\tFile is not accesible: ({Id}) from {Table}') 1029 | continue 1030 | #Retrieving dates from file. 1031 | TimeOriginalEpoch, decideflag = mediainfo (Filepath, assignstat) 1032 | if decideflag == None: 1033 | logging.info ( f"\tWe couldn't assign a date from the filename: {Filepath}") 1034 | if Event_id != -1: 1035 | # Try to assign a date from the Event if it has some photos. 1036 | # It will assign the earlier date of the even's photos. 1037 | Minimundate = dbconnection.execute("SELECT MIN(times) FROM (SELECT exposure_time as times FROM PhotoTable WHERE event_id = {0} and exposure_time != 0 UNION SELECT exposure_time as times FROM VideoTable WHERE event_id = {0} and exposure_time != 0)".format(Event_id,)).fetchone()[0] 1038 | if Minimundate == None: 1039 | logging.info ( f"\tWe couldn't assign any date from the Photoevent: {Filepath}") 1040 | # we cant't do anything, I can't guess the picture date. 1041 | continue 1042 | else: 1043 | TimeOriginalEpoch = Minimundate 1044 | else: 1045 | # we cant't do anything, I can't guess the picture date. 1046 | continue 1047 | elif Event_id == -1 : 1048 | logging.debug ("\t Searchign an event to add the item...") 1049 | ocurrences, eventID = dbconnection.execute ("SELECT count(ocurrences) as events_count, event_id from \ 1050 | (select count(event_id) as ocurrences, event_id FROM \ 1051 | (select event_id, id from PhotoTable WHERE exposure_time < {0} and exposure_time > {1} \ 1052 | union \ 1053 | select event_id, id from VideoTable WHERE exposure_time < {0} and exposure_time > {1}) \ 1054 | group by event_id order by ocurrences desc)".format(TimeOriginalEpoch + deltatime,TimeOriginalEpoch - deltatime)).fetchone() 1055 | logging.debug ( f'\t{ocurrences} occurences found') 1056 | if ocurrences != 1 and eventID not in neweventsids: 1057 | logging.debug ('\tCreating a new event for the item.') 1058 | #Selecting next event ID 1059 | eventID = dbconnection.execute("SELECT max(id)+1 FROM EventTable").fetchone()[0] 1060 | Time_created = int(datetime.timestamp(datetime.now())) 1061 | Primary_source_id = Thumbfilepath (Id,Table)[0] 1062 | neweventsids.append (eventID) 1063 | #Inserting new event 1064 | dbconnection.execute ("INSERT INTO EventTable \ 1065 | (name,primary_photo_id,time_created,primary_source_id,comment) \ 1066 | VALUES (null,null,{},'{}',null)".format( Time_created , Primary_source_id )) 1067 | # assigning image/video exposure time 1068 | logging.debug ('\tAssigning exposure time and event to the image') 1069 | dbconnection.execute ("UPDATE {} SET exposure_time = {}, event_id = {} where id = {}".format(Table,TimeOriginalEpoch,eventID,Id)) 1070 | #Inserting metadatas in file 1071 | if commit_metadata and File_Format == 0 and TimeOriginalEpoch != None: 1072 | if dummy == False: 1073 | add_date_metadate( Filepath, TimeOriginalEpoch) 1074 | MD5 = md5hash (Filepath) 1075 | dbconnection.execute ("UPDATE {} SET md5 = '{}' where id = {}".format(Table, MD5, Id)) 1076 | logging.debug ( f'\tMetadata inserted into the image, updated md5 {MD5}.{dummymsg}') 1077 | 1078 | if dummy == False: 1079 | dbconnection.commit() 1080 | logging.debug( f'\tChanges commited.{dummymsg}') 1081 | dbnoeventcursor.close() 1082 | 1083 | totalreg = dbconnection.execute ('SELECT sum (ids) FROM (SELECT count (id) AS ids FROM phototable UNION SELECT count(id) AS ids FROM videotable )').fetchone()[0] 1084 | progress = Progresspercent (totalreg) 1085 | idcounter = 0 1086 | 1087 | # Most recent pictures routine. 1088 | if mostrecentkbs > 0 : 1089 | dballitemscursor = dbconnection.cursor () 1090 | dballitemscursor.execute ("SELECT filesize, exposure_time, rating, 'PhotoTable' as tabla FROM PhotoTable WHERE rating >= %(rating)s UNION SELECT filesize, exposure_time, rating,'VideoTable' as tabla FROM VideoTable WHERE rating >= %(rating)s ORDER BY exposure_time DESC" %{'rating':morerecent_stars} ) 1091 | acumulatedKb = 0 1092 | for entry in dballitemscursor: 1093 | acumulatedKb = acumulatedKb + entry[0] 1094 | #print (acumulatedKb) 1095 | if acumulatedKb >= mostrecentkbs: 1096 | break 1097 | datelimit2move_exposure = datetime.fromtimestamp(entry[1]) 1098 | logging.info ( f"Files earlier than {datelimit2move_exposure.strftime(r'%Y-%m-%d')} and with a rating of {morerecent_stars} or more will be sent to {librarymostrecentpath}") 1099 | dballitemscursor.close() 1100 | 1101 | # Inserting a Trash event 1102 | dbeventcursor = dbconnection.cursor () 1103 | try: 1104 | dbeventcursor.execute("INSERT INTO EventTable (id, name) VALUES (-1,'Trash')") 1105 | dbconnection.commit() 1106 | except: 1107 | pass 1108 | 1109 | # Processing events. Event cursor 1110 | dbeventcursor.execute('SELECT id,name FROM EventTable') 1111 | for e in dbeventcursor: 1112 | # Retrieve event data 1113 | eventid, eventname = e 1114 | times = dbconnection.execute('SELECT exposure_time FROM videotable WHERE event_id = ? and exposure_time != 0 UNION select exposure_time from phototable where event_id = ? and exposure_time != 0', (eventid,eventid)) 1115 | 1116 | # calculating event date by average 1117 | suma, count = 0, 0 1118 | for l in times: 1119 | count += 1 1120 | suma += l[0] 1121 | eventavgtime = 0 1122 | if count == 0: 1123 | logging.debug ( f'\tEvent {eventid} has no datable photos or videos (or is empty).') 1124 | else: 1125 | eventavgtime = suma/count 1126 | eventtime = datetime.fromtimestamp(eventavgtime) 1127 | 1128 | if eventname == None : 1129 | eventname = "" 1130 | else: 1131 | eventname = NoTAlloChReplace (eventname) # Replace not allowed character for some filesystems 1132 | 1133 | # print ("Processing event:({})".format(eventid, eventname), end='') 1134 | logging.debug ('') 1135 | logging.debug ( f'## Processing event nº {eventid}: {eventname} ({eventtime})') 1136 | 1137 | # defining event path: 1138 | if eventid == -1 or eventavgtime == 0: 1139 | if eventname == "": 1140 | # for events with no date 1141 | eventname = "Trash/event " + str(eventid) 1142 | eventpath = os.path.join(librarymainpath, eventname) 1143 | eventpathlast = os.path.join(librarymostrecentpath, eventname) 1144 | else: 1145 | if flat_tree: 1146 | main_branch_name = "" 1147 | else: 1148 | main_branch_name = eventtime.strftime('%Y') 1149 | eventpath = os.path.join(librarymainpath,main_branch_name,eventtime.strftime(r'%Y-%m-%d ') + eventname) 1150 | eventpathlast = os.path.join(librarymostrecentpath,main_branch_name,eventtime.strftime(r'%Y-%m-%d ') + eventname) 1151 | 1152 | eventpath, eventpathlast = eventpath.strip(), eventpathlast.strip() 1153 | 1154 | logging.debug ("path for the event: " + eventpath) 1155 | logging.debug ("path for the event in case of the the most recent pictures: " + eventpathlast) 1156 | 1157 | # retrieving event's photos and videos 1158 | dbtablecursor = dbconnection.cursor() 1159 | dbtablecursor.execute("SELECT id, filename, title, exposure_time, import_id, 'PhotoTable' AS DBTable, editable_id, rating, md5, flags FROM PhotoTable WHERE event_id = ? UNION SELECT id, filename, title, exposure_time, import_id, 'VideoTable' AS DBTable, -1 AS editable_id, rating, md5, flags FROM VideoTable WHERE event_id = ?",(eventid, eventid)) 1160 | 1161 | # Process each file 1162 | for p in dbtablecursor: 1163 | idcounter += 1 1164 | eventpathF = eventpath 1165 | photoid, photopath, phototitle, phototimestamp, import_id, DBTable, editable_id, stars, filemd5, Flags = p 1166 | photodate = None 1167 | if phototimestamp: 1168 | photodate = datetime.fromtimestamp(phototimestamp) 1169 | photodateimport = datetime.fromtimestamp(import_id) 1170 | photofilename = os.path.basename(photopath) 1171 | 1172 | if itemcheck (photopath) != "file": 1173 | infomsg = f"! Image or video in database is not present at this moment:{photopath}" 1174 | print (infomsg) ; logging.warning (infomsg) 1175 | continue 1176 | 1177 | # logging the editable ID, just for info. 1178 | if editable_id != -1: 1179 | editablestring = "Editable id:(" + str(editable_id) + ")" 1180 | else: 1181 | editablestring = '' 1182 | #progress.showprogress (idcounter,"Processing event:({}){}, file:({}){}.".format(eventid, eventname,photoid,editablestring)) 1183 | progress.showprogress (idcounter,"Processing entry id:{:6} ".format(photoid)) 1184 | logging.debug ( f"# Processing({photoid}) {editablestring}, filename: {photofilename}") 1185 | 1186 | # Check if file is in the last Kb to move to most recent dir. 1187 | # It also overrides files from trash beign sent to the more recent dir. 1188 | if photodate: 1189 | if mostrecentkbs != 0 and photodate > datelimit2move_exposure and stars >= morerecent_stars and eventid != -1: 1190 | logging.debug ("File will be sent to the recent pictures folder") 1191 | eventpathF = eventpathlast 1192 | 1193 | photonewfilename = photofilename 1194 | # checking a starting date in filename 1195 | sep = "" 1196 | if insertdateinfilename == True and phototimestamp and eventid != -1: 1197 | expr = r'[12]\d{3}[01]\d[0-3]\d[.-_ ]?[012]\d[0-5]\d[0-5]\d' 1198 | mo = re.search (expr, photofilename) 1199 | try: 1200 | mo.group() 1201 | except: 1202 | logging.debug (f"Predefined fulldate expression was not found in {photofilename}") 1203 | sep = " " 1204 | else: 1205 | logging.debug ("Filename already starts with a full date expression") 1206 | logging.debug ("Checking date on filename") 1207 | photofilename = photofilename [len(mo.group() ):] 1208 | if photofilename[0].lower() in '1234567809qwertyuiopasdfghjklñzxcvbnm': 1209 | sep = " " 1210 | 1211 | photonewfilename = datetime.strftime(photodate, r'%Y%m%d_%H%M%S') + sep + photofilename 1212 | logging.debug (f"Filename will be renamed as: {photonewfilename}") 1213 | 1214 | 1215 | 1216 | # Setting the destination 1217 | if Flags != 4 and (eventid == -1 or eventavgtime== 0): 1218 | logging.debug ('This file goes to the no-date folder') 1219 | eventpathF = eventpathF.replace('/Trash','/no_event',1) 1220 | 1221 | # (option) import title from filenames 1222 | if importtitlefromfilenames == True and phototitle == None: 1223 | phototitle = extracttitle (os.path.splitext(photofilename)[0]) 1224 | # Changing Title pointer 1225 | if dummy == False: 1226 | dbconnection.execute ( f'UPDATE {DBTable} SET title = ? where id = ?', ( phototitle, photoid)) 1227 | logging.debug ( f"Entry {photoid}, title updated at table {DBTable}. Title:{phototitle} {dummymsg}") 1228 | 1229 | # writting titles from database to file 1230 | # database title = Extracted title = phototitle 1231 | fileextension:str = os.path.splitext (photofilename)[1] 1232 | if inserttitlesinfiles == True and phototitle != None and fileextension.lower() in ['.jpg']: 1233 | try: 1234 | image_metadata = GExiv2.Metadata(photopath) 1235 | except: 1236 | logging.warning ('\tAn error occurred during obtaining metadata on this file') 1237 | else: 1238 | if image_metadata.get('Iptc.Application2.Caption') != phototitle: 1239 | mydictofmetadatas = { 1240 | 'Iptc.Application2.Caption': phototitle, 1241 | 'Iptc.Application2.Headline': phototitle, 1242 | 'Xmp.dc.title': 'lang="x-default" ' + phototitle, 1243 | 'Xmp.photoshop.Headline' : phototitle, 1244 | } 1245 | 1246 | for x in mydictofmetadatas: 1247 | image_metadata.set_tag_string (x, mydictofmetadatas[x]) 1248 | if dummy == False : 1249 | image_metadata.save_file() 1250 | logging.info ( f"\tImage title metadata has been updated with database title: {phototitle}{dummymsg}") 1251 | 1252 | photonewfilename = NoTAlloChReplace (photonewfilename) # Replace not allowed Characters on filename for some filesystems 1253 | dest = os.path.join (eventpathF, photonewfilename) 1254 | logging.debug ("destination is set to :" + dest) 1255 | 1256 | ## Deletes thumbnails due a condition. Shotwell will restore deleted thumbnails 1257 | ''' 1258 | if editable_id != -1: 1259 | Deletethumb (photoid) 1260 | ''' 1261 | 1262 | ## Checks the md5 hash of the files and it compares it with the DB 1263 | # Note that Shotwell updates the md5hash if the file has changed externally 1264 | ''' 1265 | fh = md5hash (photopath) 1266 | logging.debug ("md5 in DB is the same as the file: {}".format(fh == filemd5)) 1267 | ''' 1268 | 1269 | # file operations 1270 | if photopath == dest: 1271 | infomsg = "This file is already on its destination. This file remains on its place." 1272 | logging.debug (infomsg) 1273 | continue 1274 | else: 1275 | #moving files from photopath to dest 1276 | dest = filemove (photopath, dest) 1277 | # Changing DB pointer 1278 | if dummy == False: 1279 | dbconnection.execute ( f'UPDATE {DBTable} SET filename = ? where id = ?', (dest, photoid)) 1280 | # adding a folder to scan 1281 | foldercollection.add (os.path.dirname(photopath)) 1282 | logging.debug (os.path.dirname(photopath) + ' added to folders list') 1283 | logging.debug ( f"Entry {photoid} updated at table {DBTable}. {dummymsg}") 1284 | 1285 | # Checking externally edited photos. Backups images are sent besides modified images. 1286 | if editable_id != -1: 1287 | editable_photo = dbconnection.execute ( f'SELECT filepath FROM BackingPhotoTable WHERE id = {editable_id}').fetchone()[0] 1288 | editable_dest = os.path.splitext(dest)[0] + '_modified' + os.path.splitext(dest)[1] 1289 | if os.path.dirname(editable_photo) == os.path.dirname(editable_dest) and editable_photo == editable_dest: 1290 | infomsg = "This file is already on its destination. This file remains on its place." 1291 | logging.debug (infomsg) 1292 | continue 1293 | else: 1294 | #moving files from editable_photo to editable_dest 1295 | result = filemove (editable_photo, editable_dest) 1296 | if result != None: 1297 | editable_dest = result 1298 | foldercollection.add (os.path.dirname(editable_photo)) 1299 | logging.debug (os.path.dirname(editable_photo) + ' added to folders list') 1300 | # Changing DB pointer 1301 | if dummy == False: 1302 | dbconnection.execute ('UPDATE BackingPhotoTable SET filepath = ? where id = ?', (editable_dest, editable_id)) 1303 | logging.debug ( f"Entry {editable_id} updated at table BackingPhotoTable. {dummymsg}") 1304 | else: 1305 | infomsg = f"Cannot find editable file id({editable_id}): {editable_photo}" 1306 | logging.warning (infomsg) 1307 | 1308 | dbtablecursor.close() 1309 | 1310 | # Deleting Trash event and closing connections 1311 | dbeventcursor.execute("DELETE FROM EventTable WHERE id = -1") 1312 | dbeventcursor.close() 1313 | dbconnection.commit() 1314 | logging.debug ("Changes were commited") 1315 | 1316 | # Cleaning empty folders 1317 | if clearfolders == True: 1318 | logging.info ('== Checking empty folders to delete them ==') 1319 | foldercollectionnext = set() 1320 | while len(foldercollection) > 0: 1321 | for i in foldercollection: 1322 | logging.debug ( f'checking: {i}') 1323 | if itemcheck(i) != 'folder': 1324 | logging.warning ('\tDoes not exists or is not a folder. Skipping') 1325 | continue 1326 | if len (os.listdir(i)) == 0: 1327 | if i == os.path.join (UserHomePath,'Desktop'): 1328 | logging.warning ('I will not delete your Desktop directory.') 1329 | continue 1330 | shutil.rmtree (i) 1331 | ftext = i 1332 | if len (ftext) > 50: 1333 | ftext = "..." + ftext [-47:] 1334 | print ( f" empty folder removed: {ftext}") 1335 | logging.info ( f"Empty folder removed: {i}") 1336 | foldercollectionnext.add (os.path.dirname(i)) 1337 | logging.debug ("\tadded next level to re-scan") 1338 | foldercollection = foldercollectionnext 1339 | foldercollectionnext = set() 1340 | 1341 | # Checking and Converting MOV files 1342 | if conv_mov and ffmpeg: 1343 | logging.debug ('Querying DB for video conversions.') 1344 | newImportID = int(now.timestamp()) 1345 | dbMOVcursor = dbconnection.cursor() 1346 | dbMOVcursor.execute ( 1347 | "SELECT ROUND ((filesize/clip_duration)/(width*height/1000)) AS bitrate,* FROM videotable WHERE \ 1348 | filename LIKE '%{0}.{1}' ESCAPE '/' \ 1349 | AND bitrate > {2} \ 1350 | AND filename NOT LIKE '%/_c.mov' ESCAPE '/' \ 1351 | AND filename NOT LIKE '%/_f.{1}' ESCAPE '/' \ 1352 | AND rating > -1 \ 1353 | AND (event_id <> -1 OR (event_id = -1 and exposure_time = 0))".format (conv_flag_q, conv_extension_q, conv_bitrate_kbs,) 1354 | ) 1355 | for entry in dbMOVcursor: 1356 | Entry_id = entry [1] 1357 | sourcefile = entry[2] 1358 | Entry_width = entry [3] 1359 | Entry_height = entry [4] 1360 | Entry_clip_duration = entry [5] 1361 | #Entry_is_interpretable = entry [6] 1362 | Entry_filesize = entry [7] 1363 | Entry_timestamp = entry [8] 1364 | Entry_exposure_time = entry [9] 1365 | #Entry_import_id = entry [10] 1366 | Entry_event_id = entry [11] 1367 | #Entry_md5 = entry [12] 1368 | #Entry_time_created [13] 1369 | Entry_rating = entry [14] 1370 | Entry_title = entry [15] 1371 | #Entry_backlinks = entry [16] 1372 | #Entry_time_reimported = entry [17] 1373 | #Entry_flags = entry [18] 1374 | Entry_comment = entry [19] 1375 | 1376 | if itemcheck (sourcefile) != 'file': 1377 | logging.warning( '\tThis file cannot be accessed, or does not exist at this very moment.') 1378 | continue 1379 | 1380 | Entry_tag_id = f'video-{Entry_id:016x},' 1381 | metadataparam = '' 1382 | if Entry_exposure_time != 0: 1383 | videoCreationTime = datetime.fromtimestamp ( Entry_exposure_time) 1384 | videoStringTime = datetime.isoformat( videoCreationTime, timespec='microseconds') + 'Z' # Example: 2018-01-03T18:25:34.000000Z 1385 | metadataparam += f'-metadata creation_time="{videoStringTime}"' 1386 | 1387 | logging.info ( f'Processing file with ffmpeg: {sourcefile}') 1388 | newFilename = os.path.splitext(sourcefile)[0]+'_c.mov' 1389 | if itemcheck (newFilename) == 'file': 1390 | if dummy == False: 1391 | os.remove(newFilename) 1392 | logging.warning ( f'\tIt seems that an old converted file was there, it has been deleted.{dummymsg}') 1393 | 1394 | if dummy == False: 1395 | logging.info( f'\tConverting video {newFilename}') 1396 | ffmpeg_status = os.system (f'ffmpeg -i "{sourcefile}" {metadataparam} "{newFilename}"') 1397 | else: 1398 | ffmpeg_status = 0 1399 | logging.debug( f'\tffmpeg exitted with code: {ffmpeg_status}{dummymsg}') 1400 | 1401 | if getappstatus (['shotwell']): 1402 | print( '\nWARNING: Shotwell process is running, I will not run meanwhile Shotwell application is running.') 1403 | logging.warning( 'Shotwell process is running. Aborting current conversion.') 1404 | ffmpeg_status = None # Exit conversion sesion. 1405 | 1406 | if ffmpeg_status == 0: 1407 | # (ffmpeg exitted with no errors) 1408 | logging.debug( '\tFile converted, adding or updating new entries to DB') 1409 | # Getting new values for update DB registry. 1410 | newMD5 = 0 1411 | newFilesize = 0 1412 | if dummy == False: 1413 | newMD5 = md5hash (newFilename) 1414 | newFilesize = os.path.getsize (newFilename) 1415 | newEntry = (None, 1416 | newFilename, 1417 | Entry_width, 1418 | Entry_height, 1419 | Entry_clip_duration, 1420 | 1, 1421 | newFilesize, 1422 | Entry_timestamp, 1423 | Entry_exposure_time, 1424 | newImportID, 1425 | Entry_event_id, 1426 | newMD5, 1427 | int(now.timestamp()), 1428 | Entry_rating, 1429 | Entry_title, 1430 | None, 1431 | None, 1432 | 0, 1433 | Entry_comment 1434 | ) 1435 | # Fetching videoentry for an already converted video. 1436 | videoConvlineID = dbconnection.execute ('SELECT id FROM videotable WHERE filename=? ', (newFilename,)).fetchone() 1437 | if videoConvlineID is None: 1438 | logging.debug ( f'\tInserting new line at VideoTable.{dummymsg}') 1439 | if dummy == False: 1440 | dbconnection.execute ('INSERT INTO videotable VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ', newEntry ) 1441 | # Adding new videofiles to tag table (cloning values) 1442 | dbconnection.commit () 1443 | newEntry_id = dbconnection.execute ('SELECT max(id) FROM videotable').fetchone()[0] 1444 | newVideoTag_id = f'video-{newEntry_id:016x},' 1445 | TagCursor = dbconnection.cursor () 1446 | logging.debug ( f'\tSelecting tags for entry {Entry_tag_id}') 1447 | TagCursor.execute ("SELECT id, photo_id_list FROM tagtable WHERE photo_id_list LIKE ?", (f'%{Entry_tag_id}%',)) 1448 | for TagEntry in TagCursor: 1449 | lineID , tagtext = TagEntry[0], TagEntry[1] 1450 | newTagText = tagtext + newVideoTag_id 1451 | dbconnection.execute ('UPDATE tagtable SET photo_id_list=? WHERE id=?',(newTagText,lineID)) 1452 | 1453 | else: 1454 | logging.debug ( f'\tUpdating an existent registry for converted video.{dummymsg}') 1455 | # This will not update or clone the tag registry, it will preserve existent converted video tag attributes and rating. 1456 | if dummy == False: 1457 | dbconnection.execute ('UPDATE videotable SET filesize=?, import_id=?, md5=?, time_created=? WHERE id = ?', (newFilesize, newImportID, newMD5, int(now.timestamp()), videoConvlineID[0])) 1458 | 1459 | # Set original video as rejected. (rating = -1) 1460 | if dummy == False: 1461 | dbconnection.execute ('UPDATE videotable SET rating=-1 WHERE id = ?', (Entry_id,)) 1462 | 1463 | else: 1464 | # ffmpeg encounterered errors 1465 | if dummy == False: 1466 | if itemcheck (newFilename) == 'file': 1467 | os.remove(newFilename) 1468 | if ffmpeg_status is not None: 1469 | failedName = os.path.splitext(sourcefile)[0]+'_f{}'.format (os.path.splitext(sourcefile)[1]) 1470 | os.rename (sourcefile, failedName) 1471 | dbconnection.execute('UPDATE videotable SET filename=? WHERE id=?', (failedName,Entry_id)) 1472 | else: 1473 | break 1474 | 1475 | if dummy == False: 1476 | dbconnection.commit() 1477 | 1478 | dbMOVcursor.close() 1479 | 1480 | # Fixing missing videos 1481 | if fix_missingvideos: 1482 | logging.info ('Searching missed Videos') 1483 | dbFIXVIDEOCursor = dbconnection.cursor() 1484 | dbFIXVIDEOCursor.execute ("SELECT id, filename, md5, timestamp, exposure_time FROM videotable WHERE flags == 2 ") 1485 | if dbFIXVIDEOCursor.rowcount: 1486 | logging.info ('Fixing missed Videos') 1487 | for entry in dbFIXVIDEOCursor: 1488 | VT_id = entry [0] 1489 | VT_filename = entry [1] 1490 | VT_md5 = entry [2] 1491 | VT_timestamp = entry [3] 1492 | VT_exposure_time = entry [4] 1493 | 1494 | print (f'Searching missing video file for video id({VT_id})...') 1495 | fmf = find_my_file(mainpath=librarymainpath, filename = os.path.basename(VT_filename), filemd5 = VT_md5) 1496 | if fmf.is_found and fmf.md5match and fmf.foundpath != VT_filename and itemcheck (VT_filename) == '': 1497 | #moving file to new path 1498 | if not dummy: 1499 | if itemcheck (os.path.dirname(VT_filename)) == '': 1500 | os.makedirs (os.path.dirname(VT_filename)) 1501 | result = os.rename (fmf.foundpath, VT_filename) 1502 | logging.info ( f'File for video id({VT_id}) was found at: {fmf.foundpath} and moved to its original location: {VT_filename}') 1503 | else: 1504 | logging.info ('No missing videos found.') 1505 | # Closing db Connection 1506 | dbconnection.close () 1507 | logging.debug ("DB connection was closed") 1508 | 1509 | # Updating LastExec file and sleeping if daemonmode is active 1510 | if daemonmode: 1511 | if execution: 1512 | f = open (lastExecFile, 'wb') 1513 | LastExec = datetime.now() 1514 | logging.debug ('Creating/updating LastExecFile.dump') 1515 | pickle.dump (LastExec, f) 1516 | f.close() 1517 | if sleepseconds > 0: 1518 | time.sleep (sleepseconds) 1519 | else: 1520 | break 1521 | else: 1522 | break 1523 | print ('\nDone!') --------------------------------------------------------------------------------