├── 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!')
--------------------------------------------------------------------------------