summaryrefslogtreecommitdiff
path: root/src/MetadataWriter.vala
diff options
context:
space:
mode:
Diffstat (limited to 'src/MetadataWriter.vala')
-rw-r--r--src/MetadataWriter.vala675
1 files changed, 675 insertions, 0 deletions
diff --git a/src/MetadataWriter.vala b/src/MetadataWriter.vala
new file mode 100644
index 0000000..aee5855
--- /dev/null
+++ b/src/MetadataWriter.vala
@@ -0,0 +1,675 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// MetadataWriter tracks LibraryPhotos for alterations to their metadata and commits those changes
+// in a timely manner to their backing files. Because only the MetadataWriter knows when the
+// metadata has been properly committed, it is also responsible for updating the metadata-dirty
+// flag in Photo. Thus, MetadataWriter should *always* be running, even if the user has turned off
+// the feature, so if they turn it on MetadataWriter can properly go out and update the backing
+// files.
+
+public class MetadataWriter : Object {
+ public const uint COMMIT_DELAY_MSEC = 3000;
+ public const uint COMMIT_SPACING_MSEC = 50;
+
+ private const string[] INTERESTED_PHOTO_METADATA_DETAILS = { "name", "comment", "rating", "exposure-time" };
+
+ private class CommitJob : BackgroundJob {
+ public LibraryPhoto photo;
+ public Gee.Set<string>? current_keywords;
+ public Photo.ReimportMasterState reimport_master_state = null;
+ public Photo.ReimportEditableState reimport_editable_state = null;
+ public Error? err = null;
+
+ public CommitJob(MetadataWriter owner, LibraryPhoto photo, Gee.Set<string>? keywords) {
+ base (owner, owner.on_update_completed, new Cancellable(), owner.on_update_cancelled);
+
+ this.photo = photo;
+ current_keywords = keywords;
+ }
+
+ public override void execute() {
+ try {
+ commit_master();
+ commit_editable();
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+
+ private void commit_master() throws Error {
+ // If we have an editable, any orientation changes should be written only to it;
+ // otherwise, we'll end up ruining the original, and as such, breaking the
+ // ability to revert to it.
+ bool skip_orientation = photo.has_editable();
+
+ if (!photo.get_master_file_format().can_write_metadata())
+ return;
+
+ PhotoMetadata metadata = photo.get_master_metadata();
+ if (update_metadata(metadata, skip_orientation)) {
+ LibraryMonitor.blacklist_file(photo.get_master_file(), "MetadataWriter.commit_master");
+ try {
+ photo.persist_master_metadata(metadata, out reimport_master_state);
+ } finally {
+ LibraryMonitor.unblacklist_file(photo.get_master_file());
+ }
+ }
+ }
+
+ private void commit_editable() throws Error {
+ if (!photo.has_editable() || !photo.get_editable_file_format().can_write_metadata())
+ return;
+
+ PhotoMetadata? metadata = photo.get_editable_metadata();
+ assert(metadata != null);
+
+ if (update_metadata(metadata)) {
+ LibraryMonitor.blacklist_file(photo.get_editable_file(), "MetadataWriter.commit_editable");
+ try {
+ photo.persist_editable_metadata(metadata, out reimport_editable_state);
+ } finally {
+ LibraryMonitor.unblacklist_file(photo.get_editable_file());
+ }
+ }
+ }
+
+ private bool update_metadata(PhotoMetadata metadata, bool skip_orientation = false) {
+ bool changed = false;
+
+ // title (caption)
+ string? current_title = photo.get_title();
+ if (current_title != metadata.get_title()) {
+ metadata.set_title(current_title);
+ changed = true;
+ }
+
+ // comment
+ string? current_comment = photo.get_comment();
+ if (current_comment != metadata.get_comment()) {
+ metadata.set_comment(current_comment);
+ changed = true;
+ }
+
+ // rating
+ Rating current_rating = photo.get_rating();
+ if (current_rating != metadata.get_rating()) {
+ metadata.set_rating(current_rating);
+ changed = true;
+ }
+
+ // exposure date/time
+ time_t current_exposure_time = photo.get_exposure_time();
+ time_t metadata_exposure_time = 0;
+ MetadataDateTime? metadata_exposure_date_time = metadata.get_exposure_date_time();
+ if (metadata_exposure_date_time != null)
+ metadata_exposure_time = metadata_exposure_date_time.get_timestamp();
+ if (current_exposure_time != metadata_exposure_time) {
+ metadata.set_exposure_date_time(current_exposure_time != 0
+ ? new MetadataDateTime(current_exposure_time)
+ : null);
+ changed = true;
+ }
+
+ // tags (keywords) ... replace (or clear) entirely rather than union or intersection
+ Gee.Set<string> safe_keywords = new Gee.HashSet<string>();
+
+ // Since the tags are stored in an image file's `keywords' field in
+ // non-hierarchical format, before checking whether the tags that
+ // should be associated with this image have been written, we'll need
+ // to produce non-hierarchical versions of the tags to be tested.
+ // get_user_visible_name() does this by returning the most deeply-nested
+ // portion of a given hierarchical tag; that is, for a tag "/a/b/c",
+ // it'll return "c", which is exactly the form we want here.
+ if (current_keywords != null) {
+ foreach(string tmp in current_keywords) {
+ Tag tag = Tag.for_path(tmp);
+ safe_keywords.add(tag.get_user_visible_name());
+ }
+ }
+
+ if (!equal_sets(safe_keywords, metadata.get_keywords())) {
+ metadata.set_keywords(current_keywords);
+ changed = true;
+ }
+
+ // orientation
+ if (!skip_orientation) {
+ Orientation current_orientation = photo.get_orientation();
+ if (current_orientation != metadata.get_orientation()) {
+ metadata.set_orientation(current_orientation);
+ changed = true;
+ }
+ }
+
+ // add the software name/version only if updating the metadata in the file
+ if (changed)
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ return changed;
+ }
+ }
+
+ private static MetadataWriter instance = null;
+
+ private Workers workers = new Workers(1, false);
+ private bool enabled = false;
+ private HashTimedQueue<LibraryPhoto> dirty;
+ private Gee.HashMap<LibraryPhoto, CommitJob> pending = new Gee.HashMap<LibraryPhoto, CommitJob>();
+ private Gee.HashSet<string> interested_photo_details = new Gee.HashSet<string>();
+ private LibraryPhoto? ignore_photo_alteration = null;
+ private uint outstanding_total = 0;
+ private uint outstanding_completed = 0;
+ private bool closed = false;
+ private int pause_count = 0;
+ private Gee.HashSet<LibraryPhoto> importing_photos = new Gee.HashSet<LibraryPhoto>();
+
+ public signal void progress(uint completed, uint total);
+
+ private MetadataWriter() {
+ dirty = new HashTimedQueue<LibraryPhoto>(COMMIT_DELAY_MSEC, on_photo_dequeued);
+ dirty.set_dequeue_spacing_msec(COMMIT_SPACING_MSEC);
+
+ // start with the writer paused, waiting for the LibraryMonitor initial discovery to
+ // complete (note that if the LibraryMonitor is ever disabled, the MetadataWriter will not
+ // start on its own)
+ pause();
+
+ // convert all interested metadata Alteration details into lookup hash
+ foreach (string detail in INTERESTED_PHOTO_METADATA_DETAILS)
+ interested_photo_details.add(detail);
+
+ // sync up with the configuration system
+ enabled = Config.Facade.get_instance().get_commit_metadata_to_masters();
+ Config.Facade.get_instance().commit_metadata_to_masters_changed.connect(on_config_changed);
+
+ // add all current photos to look for ones that are dirty and need updating
+ force_rescan();
+
+ LibraryPhoto.global.media_import_starting.connect(on_importing_photos);
+ LibraryPhoto.global.media_import_completed.connect(on_photos_imported);
+ LibraryPhoto.global.contents_altered.connect(on_photos_added_removed);
+ LibraryPhoto.global.items_altered.connect(on_photos_altered);
+ LibraryPhoto.global.frozen.connect(on_collection_frozen);
+ LibraryPhoto.global.thawed.connect(on_collection_thawed);
+ LibraryPhoto.global.items_destroyed.connect(on_photos_destroyed);
+
+ Tag.global.items_altered.connect(on_tags_altered);
+ Tag.global.container_contents_altered.connect(on_tag_contents_altered);
+ Tag.global.backlink_to_container_removed.connect(on_tag_backlink_removed);
+ Tag.global.frozen.connect(on_collection_frozen);
+ Tag.global.thawed.connect(on_collection_thawed);
+
+ Application.get_instance().exiting.connect(on_application_exiting);
+
+ LibraryMonitorPool.get_instance().monitor_installed.connect(on_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.connect(on_monitor_destroyed);
+ }
+
+ ~MetadataWriter() {
+ Config.Facade.get_instance().commit_metadata_to_masters_changed.disconnect(on_config_changed);
+
+ LibraryPhoto.global.media_import_starting.disconnect(on_importing_photos);
+ LibraryPhoto.global.media_import_completed.disconnect(on_photos_imported);
+ LibraryPhoto.global.contents_altered.disconnect(on_photos_added_removed);
+ LibraryPhoto.global.items_altered.disconnect(on_photos_altered);
+ LibraryPhoto.global.frozen.disconnect(on_collection_frozen);
+ LibraryPhoto.global.thawed.disconnect(on_collection_thawed);
+ LibraryPhoto.global.items_destroyed.disconnect(on_photos_destroyed);
+
+ Tag.global.items_altered.disconnect(on_tags_altered);
+ Tag.global.container_contents_altered.disconnect(on_tag_contents_altered);
+ Tag.global.backlink_to_container_removed.disconnect(on_tag_backlink_removed);
+ Tag.global.frozen.disconnect(on_collection_frozen);
+ Tag.global.thawed.disconnect(on_collection_thawed);
+
+ Application.get_instance().exiting.disconnect(on_application_exiting);
+
+ LibraryMonitorPool.get_instance().monitor_installed.disconnect(on_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.disconnect(on_monitor_destroyed);
+ }
+
+ public static void init() {
+ instance = new MetadataWriter();
+ }
+
+ public static void terminate() {
+ if (instance != null)
+ instance.close();
+
+ instance = null;
+ }
+
+ public static MetadataWriter get_instance() {
+ return instance;
+ }
+
+ // This will examine all photos for dirty metadata and schedule commits if enabled.
+ public void force_rescan() {
+ schedule_if_dirty((Gee.Collection<LibraryPhoto>) LibraryPhoto.global.get_all(), "force rescan");
+ }
+
+ public void pause() {
+ if (pause_count++ != 0)
+ return;
+
+ dirty.pause();
+
+ progress(0, 0);
+ }
+
+ public void unpause() {
+ if (pause_count == 0 || --pause_count != 0)
+ return;
+
+ dirty.unpause();
+ }
+
+ public void close() {
+ if (closed)
+ return;
+
+ cancel_all(true);
+
+ closed = true;
+ }
+
+ private void on_config_changed() {
+ bool value = Config.Facade.get_instance().get_commit_metadata_to_masters();
+
+ if (enabled == value)
+ return;
+
+ enabled = value;
+ if (enabled)
+ force_rescan();
+ else
+ cancel_all(false);
+ }
+
+ private void on_application_exiting() {
+ close();
+ }
+
+ private void on_monitor_installed(LibraryMonitor monitor) {
+ monitor.discovery_completed.connect(on_discovery_completed);
+ }
+
+ private void on_monitor_destroyed(LibraryMonitor monitor) {
+ monitor.discovery_completed.disconnect(on_discovery_completed);
+ }
+
+ private void on_discovery_completed() {
+ unpause();
+ }
+
+ private void on_collection_frozen() {
+ pause();
+ }
+
+ private void on_collection_thawed() {
+ unpause();
+ }
+
+ private void on_importing_photos(Gee.Collection<MediaSource> media_sources) {
+ importing_photos.add_all((Gee.Collection<LibraryPhoto>) media_sources);
+ }
+
+ private void on_photos_imported(Gee.Collection<MediaSource> media_sources) {
+ importing_photos.remove_all((Gee.Collection<LibraryPhoto>) media_sources);
+ }
+
+ private void on_photos_added_removed(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ // no reason to go through this exercise if auto-commit is disabled
+ if (added != null && enabled)
+ schedule_if_dirty((Gee.Iterable<LibraryPhoto>) added, "added to LibraryPhoto.global");
+
+ // want to cancel jobs no matter what, however
+ if (removed != null) {
+ bool cancelled = false;
+ foreach (DataObject object in removed)
+ cancelled = cancel_job((LibraryPhoto) object) || cancelled;
+
+ if (cancelled)
+ progress(outstanding_completed, outstanding_total);
+ }
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> items) {
+ Gee.HashSet<LibraryPhoto> photos = null;
+ foreach (DataObject object in items.keys) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ // ignore this signal on this photo (means it's coming up from completing the metadata
+ // update)
+ if (photo == ignore_photo_alteration)
+ continue;
+
+ Alteration alteration = items.get(object);
+
+ // if an image:orientation detail, write that out
+ if (alteration.has_detail("image", "orientation")) {
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ photos.add(photo);
+
+ continue;
+ }
+
+ // get all "metadata" details for this alteration
+ Gee.Collection<string>? details = alteration.get_details("metadata");
+ if (details == null)
+ continue;
+
+ // only enqueue an update if an alteration of metadata actually written out occurs
+ foreach (string detail in details) {
+ if (interested_photo_details.contains(detail)) {
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ photos.add(photo);
+
+ break;
+ }
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, "alteration", false);
+ }
+
+ private void on_photos_destroyed(Gee.Collection<DataSource> destroyed) {
+ foreach (DataSource source in destroyed) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+ cancel_job(photo);
+ importing_photos.remove(photo);
+ }
+ }
+
+ private void on_tags_altered(Gee.Map<DataObject, Alteration> map) {
+ Gee.HashSet<LibraryPhoto>? photos = null;
+ foreach (DataObject object in map.keys) {
+ if (!map.get(object).has_detail("metadata", "name"))
+ continue;
+
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ foreach (MediaSource media in ((Tag) object).get_sources()) {
+ LibraryPhoto? photo = media as LibraryPhoto;
+ if (photo != null)
+ photos.add(photo);
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, "tag renamed", false);
+ }
+
+ private void on_tag_contents_altered(ContainerSource container, Gee.Collection<DataSource>? added,
+ bool relinking, Gee.Collection<DataSource>? removed, bool unlinking) {
+ Tag tag = (Tag) container;
+
+ if (added != null && !relinking) {
+ Gee.ArrayList<LibraryPhoto> added_photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in added) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null && !importing_photos.contains(photo))
+ added_photos.add(photo);
+ }
+
+ photos_are_dirty(added_photos, "added to %s".printf(tag.to_string()), false);
+ }
+
+ if (removed != null && !unlinking) {
+ Gee.ArrayList<LibraryPhoto> removed_photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in removed) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null)
+ removed_photos.add(photo);
+ }
+
+ photos_are_dirty(removed_photos, "removed from %s".printf(tag.to_string()), false);
+ }
+ }
+
+ private void on_tag_backlink_removed(ContainerSource container, Gee.Collection<DataSource> sources) {
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in sources) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null)
+ photos.add(photo);
+ }
+
+ photos_are_dirty(photos, "backlink removed from %s".printf(container.to_string()), false);
+ }
+
+ private void count_enqueued_work(int count, bool report) {
+ outstanding_total += count;
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs enqueued", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void count_cancelled_work(int count, bool report) {
+ outstanding_total = (outstanding_total >= count) ? outstanding_total - count : 0;
+ if (outstanding_completed >= outstanding_total) {
+ outstanding_completed = 0;
+ outstanding_total = 0;
+ }
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs cancelled", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void count_completed_work(int count, bool report) {
+ outstanding_completed += count;
+ if (outstanding_completed >= outstanding_total) {
+ outstanding_completed = 0;
+ outstanding_total = 0;
+ }
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs completed", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void schedule_if_dirty(Gee.Iterable<MediaSource> media_sources, string reason) {
+ Gee.ArrayList<LibraryPhoto> photos = null;
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto? photo = media as LibraryPhoto;
+ if (photo == null)
+ continue;
+
+ // if in the importing stage, do not schedule for commit
+ if (importing_photos.contains(photo))
+ continue;
+
+ if (photo.is_master_metadata_dirty()) {
+ if (photos == null)
+ photos = new Gee.ArrayList<LibraryPhoto>();
+
+ photos.add(photo);
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, reason, true);
+ }
+
+ // No photos are dirty. The human body is a thing of beauty and grace.
+ private void photos_are_dirty(Gee.Collection<LibraryPhoto> photos, string reason, bool already_marked) {
+ if (photos.size == 0)
+ return;
+
+ // cancel all outstanding and pending jobs
+ foreach (LibraryPhoto photo in photos)
+ cancel_job(photo);
+
+ // mark all the photos as dirty
+ if (!already_marked) {
+ try {
+ LibraryPhoto.global.transaction_controller.begin();
+
+ foreach (LibraryPhoto photo in photos)
+ photo.set_master_metadata_dirty(true);
+
+ LibraryPhoto.global.transaction_controller.commit();
+ } catch (Error err) {
+ if (err is DatabaseError)
+ AppWindow.database_error((DatabaseError) err);
+ else
+ error("Unable to mark metadata as dirty: %s", err.message);
+ }
+ }
+
+ // ok to drop this on the floor, now that they're marked dirty (will attempt to write them
+ // out the next time MetadataWriter runs)
+ if (closed || !enabled)
+ return;
+
+#if TRACE_METADATA_WRITER
+ debug("[%s] adding %d photos to dirty list", reason, photos.size);
+#endif
+
+ foreach (LibraryPhoto photo in photos) {
+ bool enqueued = dirty.enqueue(photo);
+ assert(enqueued);
+ }
+
+ count_enqueued_work(photos.size, true);
+ }
+
+ private void cancel_all(bool wait) {
+ dirty.clear();
+
+ foreach (CommitJob job in pending.values)
+ job.cancel();
+
+ if (wait)
+ workers.wait_for_empty_queue();
+
+ count_cancelled_work(int.MAX, true);
+ }
+
+ private bool cancel_job(LibraryPhoto photo) {
+ bool cancelled = false;
+
+ if (pending.has_key(photo)) {
+ pending.get(photo).cancel();
+ cancelled = true;
+ }
+
+ if (dirty.contains(photo)) {
+ bool removed = dirty.remove_first(photo);
+ assert(removed);
+
+ assert(!dirty.contains(photo));
+
+ count_cancelled_work(1, false);
+ cancelled = true;
+ }
+
+ return cancelled;
+ }
+
+ private void on_photo_dequeued(LibraryPhoto photo) {
+ if (!enabled) {
+ count_cancelled_work(1, true);
+
+ return;
+ }
+
+ Gee.Set<string>? keywords = null;
+ Gee.Collection<Tag>? tags = Tag.global.fetch_for_source(photo);
+ if (tags != null) {
+ keywords = new Gee.HashSet<string>();
+ foreach (Tag tag in tags)
+ keywords.add(tag.get_name());
+ }
+
+ CommitJob job = new CommitJob(this, photo, keywords);
+ pending.set(photo, job);
+
+#if TRACE_METADATA_WRITER
+ debug("%s dequeued for metadata commit, %d pending", photo.to_string(), pending.size);
+#endif
+
+ workers.enqueue(job);
+ }
+
+ private void on_update_completed(BackgroundJob j) {
+ CommitJob job = (CommitJob) j;
+
+ if (job.err != null)
+ warning("Unable to update metadata for %s: %s", job.photo.to_string(), job.err.message);
+ else
+ message("Completed writing metadata for %s", job.photo.to_string());
+
+ bool removed = pending.unset(job.photo);
+ assert(removed);
+
+ // since there's potentially multiple state-change operations here, use the transaction
+ // controller
+ LibraryPhoto.global.transaction_controller.begin();
+
+ if (job.reimport_master_state != null || job.reimport_editable_state != null) {
+ // finish_update_*_metadata are going to issue an "altered" signal, and we want to
+ // ignore it
+ assert(ignore_photo_alteration == null);
+ ignore_photo_alteration = job.photo;
+ try {
+ if (job.reimport_master_state != null)
+ job.photo.finish_update_master_metadata(job.reimport_master_state);
+
+ if (job.reimport_editable_state != null)
+ job.photo.finish_update_editable_metadata(job.reimport_editable_state);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ } finally {
+ // this assertion guards against reentrancy
+ assert(ignore_photo_alteration == job.photo);
+ ignore_photo_alteration = null;
+ }
+ } else {
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] No metadata changes for %s", outstanding_completed, outstanding_total,
+ job.photo.to_string());
+#endif
+ }
+
+ try {
+ job.photo.set_master_metadata_dirty(false);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ LibraryPhoto.global.transaction_controller.commit();
+
+ count_completed_work(1, true);
+ }
+
+ private void on_update_cancelled(BackgroundJob j) {
+ bool removed = pending.unset(((CommitJob) j).photo);
+ assert(removed);
+
+ count_cancelled_work(1, true);
+ }
+}
+