Skip to content

Instantly share code, notes, and snippets.

@airblade
Created January 8, 2018 15:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save airblade/71bcaa21675088e65526c9fb6b3fc810 to your computer and use it in GitHub Desktop.
Save airblade/71bcaa21675088e65526c9fb6b3fc810 to your computer and use it in GitHub Desktop.
Exports Photos.app v1.5 albums and their photos to plain directories and files.
#!/usr/bin/env ruby
# Exports albums and their photos to plain directories and files.
#
# Built for Photos.app v1.5 on OS X 10.11.6.
#
# See also:
# https://github.com/koraktor/gallerist
#
# TODO:
#
# - exclude albums from export
require 'active_record'
require 'active_record/connection_adapters/sqlite3_adapter'
require 'fileutils'
PHOTOS_ROOT = '/Users/andy/Pictures/Photos Library.photoslibrary'
LIBRARY_DATABASE = "#{PHOTOS_ROOT}/Database/Library.apdb"
IMAGE_PROXIES_DATABASE = "#{PHOTOS_ROOT}/Database/ImageProxies.apdb"
MASTERS = "#{PHOTOS_ROOT}/Masters"
EXPORT_ROOT = '/Users/andy/photos/andrew'
$symlink = true
#
# Configuration
#
# Implement regexp() function.
class ActiveRecord::ConnectionAdapters::SQLite3Adapter
alias :original_initialize :initialize
def initialize(connection, logger, connection_options, config)
original_initialize connection, logger, connection_options, config
connection.create_function('regexp', 2) do |func, pattern, expression|
regexp = Regexp.new(pattern.to_s, Regexp::IGNORECASE)
func.result = expression.to_s.match(regexp) ? 1 : 0
end
end
end
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: LIBRARY_DATABASE
)
class Base < ActiveRecord::Base
self.primary_key = 'modelId'
self.inheritance_column = nil
end
#
# Classes
#
class Album < Base
self.table_name = 'RKAlbum'
has_many :album_versions, foreign_key: 'albumId'
has_many :versions, -> { order 'imageDate ASC' }, through: :album_versions
default_scope {
where(albumSubClass: 3).
where('isHidden != 1').
where('isInTrash != 1').
where('name != "Last Import"').
where('name != "printAlbum"').
where('name NOT REGEXP "Roll \d+"').
where('name NOT REGEXP "\d{1,2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 20[01][0-9]"')
}
def dirname
@dirname ||= begin
earliest_photo_date = versions.first.image_date
formatted_date = earliest_photo_date.strftime '%Y%m'
"#{formatted_date} #{sanitize name}"
end
end
private
def sanitize(filename)
filename.gsub(%r{/}, '-')
end
end
class AlbumVersion < Base
self.table_name = 'RKAlbumVersion'
belongs_to :album, foreign_key: 'albumId'
belongs_to :version, foreign_key: 'versionId'
end
# More about the imagey stuff.
class Version < Base
self.table_name = 'RKVersion'
alias_attribute :adjustment_uuid, :adjustmentUuid
alias_attribute :image_date, :imageDate
has_many :album_versions, foreign_key: 'versionId'
has_many :albums, -> { order 'name ASC' }, through: :album_versions
belongs_to :master, foreign_key: 'masterId'
has_one :model_resource, foreign_key: 'attachedModelId' # edited photo
# https://developer.apple.com/reference/foundation/nsdate
APPLE_TIMESTAMP_OFFSET_SEC = Time.new(2001, 1, 1).to_i
SECONDS_IN_DAY = 60 * 60 * 24
UNIX_EPOCH_JULIAN = Time.at(0).to_datetime.jd
def edited?
adjustment_uuid != 'UNADJUSTEDNONRAW'
end
scope :edited, -> { where 'adjustmentUuid != "UNADJUSTEDNONRAW"' }
# TODO - timezone adjustment
def image_date
# Example raw value: 164480537 (seconds since 01.01.2001)
Time.at(self[:imageDate] + APPLE_TIMESTAMP_OFFSET_SEC)
end
def export_filename(increment: false)
increment_counter if increment
name = image_date.strftime("%Y%m%d-%H%M-#{counter}")
"#{name}#{File.extname(master.file_name).downcase}"
end
# FIXME - don't know whether this works properly
# I don't know whether this is a julian date or an offset in seconds or something else...
# def createDate
# # Example raw value: 394014430.70306
# Time.at(self[:createDate] + APPLE_TIMESTAMP_OFFSET_SEC)
# end
private
def counter
@counter ||= 1
end
def increment_counter
counter
@counter += 1
end
end
# More about the image file itself.
class Master < Base
self.table_name = 'RKMaster'
alias_attribute :image_path, :imagePath
alias_attribute :file_name, :fileName
has_one :version, foreign_key: 'masterId'
end
# An edited photo (as far as I know).
class ModelResource < Base
establish_connection(
adapter: 'sqlite3',
database: IMAGE_PROXIES_DATABASE
)
self.table_name = 'RKModelResource'
alias_attribute :resource_uuid, :resourceUuid
belongs_to :version, foreign_key: 'attachedModelId'
def image_path
# assumes photo, i.e. not video
first, second = resource_uuid[0].ord.to_s, resource_uuid[1].ord.to_s
File.join 'resources', 'modelresources', first, second, resource_uuid, filename
end
end
#
# Helpers
#
def log(msg)
puts msg
end
def copy_or_symlink(source, destination)
raise "missing #{source}" unless File.exist? source
raise "exists #{destination}" if File.exist? destination
if $symlink
log "symlink #{source} -> #{destination}"
File.symlink source, destination # absolute symlink
else
log "copy #{source} -> #{destination}"
File.cp source, destination, preserve: true
end
end
#
# Do it
#
total = copied_or_linked = 0
log "#{Time.now.strftime '%d.%m.%Y-%H:%M'} exporting photos"
Album.all.each do |album|
log "export #{album.name}"
# album directory
album_path = File.join EXPORT_ROOT, album.dirname
if Dir.exist? album_path
log "exists #{album_path}"
else
log "create #{album_path}"
Dir.mkdir album_path
end
album.versions.each do |photo|
total += 1
catch :skip do
master = File.join MASTERS, photo.master.image_path
# calculate destination file name, skipping already-exported files
destination = File.join album_path, photo.export_filename
while File.exist? destination
if FileUtils.identical? master, destination
log "exists #{master} -> #{destination}"
throw :skip
end
destination = File.join album_path, photo.export_filename(increment: true)
end
# copy/symlink file
copied_or_linked += 1
copy_or_symlink master, destination
# edited photos
if photo.edited?
total += 1
edit = photo.model_resource
source = File.join PHOTOS_ROOT, edit.image_path
target = destination
.sub(/[.](\w+)$/, '-edit.\1') # insert "-edit" at end of filename, before extension
.sub(/[.][^.]+$/, File.extname(source).downcase) # use edited file's extension
if File.exist? target
log "exists #{source} -> #{target}"
else
copied_or_linked += 1
copy_or_symlink source, target
end
end
end
end
end
log "exported #{total} total, #{copied_or_linked} new"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment