/* Copyright 2016 Software Freedom Conservancy Inc. * * 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", "gps" }; private class CommitJob : BackgroundJob { public LibraryPhoto photo; public Gee.Set? current_keywords; public Photo.ReimportMasterState reimport_master_state = null; public Photo.ReimportEditableState reimport_editable_state = null; public Error? err = null; public bool wrote_master = false; public bool wrote_editable = false; public CommitJob(MetadataWriter owner, LibraryPhoto photo, Gee.Set? 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()); } } wrote_master = true; } 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()); } } wrote_editable = true; } 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 DateTime? current_exposure_time = photo.get_exposure_time(); DateTime? metadata_exposure_time = null; 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 (nullsafe_date_time_comperator(current_exposure_time, metadata_exposure_time) != 0) { metadata.set_exposure_date_time(current_exposure_time != null ? new MetadataDateTime(current_exposure_time) : null); changed = true; } // gps location GpsCoords current_gps_coords = photo.get_gps_coords(); GpsCoords metadata_gps_coords = metadata.get_gps_coords(); if (!current_gps_coords.equals(ref metadata_gps_coords)) { metadata.set_gps_coords(current_gps_coords); changed = true; } // tags (keywords) ... replace (or clear) entirely rather than union or intersection Gee.Set safe_keywords = new Gee.HashSet(); // 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 dirty; private Gee.HashMap pending = new Gee.HashMap(); private Gee.HashSet pending_cancel = new Gee.HashSet(); private Gee.HashSet interested_photo_details = new Gee.HashSet(); 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 importing_photos = new Gee.HashSet(); public signal void progress(uint completed, uint total); private MetadataWriter() { dirty = new HashTimedQueue(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.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 media_sources) { importing_photos.add_all((Gee.Collection) media_sources); } private void on_photos_imported(Gee.Collection media_sources) { importing_photos.remove_all((Gee.Collection) media_sources); } private void on_photos_added_removed(Gee.Iterable? added, Gee.Iterable? removed) { // no reason to go through this exercise if auto-commit is disabled if (added != null && enabled) schedule_if_dirty((Gee.Iterable) 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 items) { Gee.HashSet 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(); photos.add(photo); continue; } // get all "metadata" details for this alteration Gee.Collection? 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(); photos.add(photo); break; } } } if (photos != null) photos_are_dirty(photos, "alteration", false); } private void on_photos_destroyed(Gee.Collection destroyed) { foreach (DataSource source in destroyed) { LibraryPhoto photo = (LibraryPhoto) source; cancel_job(photo); importing_photos.remove(photo); } } private void on_tags_altered(Gee.Map map) { Gee.HashSet? photos = null; foreach (DataObject object in map.keys) { if (!map.get(object).has_detail("metadata", "name")) continue; if (photos == null) photos = new Gee.HashSet(); 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? added, bool relinking, Gee.Collection? removed, bool unlinking) { Tag tag = (Tag) container; if (added != null && !relinking) { Gee.ArrayList added_photos = new Gee.ArrayList(); 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 removed_photos = new Gee.ArrayList(); 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 sources) { Gee.ArrayList photos = new Gee.ArrayList(); 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 media_sources, string reason) { Gee.ArrayList 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(); 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 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)) { CommitJob j = (CommitJob) pending.get(photo); pending_cancel.add(j); j.cancel(); pending.unset(photo); 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? keywords = null; Gee.Collection? tags = Tag.global.fetch_for_source(photo); if (tags != null) { keywords = new Gee.HashSet(); foreach (Tag tag in tags) keywords.add(tag.get_name()); } // check if there is already a job for that photo. if yes, cancel it. if (pending.has_key(photo)) cancel_job(photo); 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 write metadata to %s: %s", job.photo.to_string(), job.err.message); } else { if (job.wrote_master) message("Completed writing metadata to %s", job.photo.get_master_file().get_path()); else message("Unable to write metadata to %s", job.photo.get_master_file().get_path()); if (job.photo.get_editable_file() != null) { if (job.wrote_editable) message("Completed writing metadata to %s", job.photo.get_editable_file().get_path()); else message("Unable to write metadata to %s", job.photo.get_editable_file().get_path()); } } 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 (Error 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_cancel.remove((CommitJob) j); assert(removed); count_cancelled_work(1, true); } }