flickarr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ require 'date'
2
+ require 'down'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'slugify'
6
+ require 'yaml'
7
+
8
+ module Flickarr
9
+ class Post
10
+ FLICKR_URL_PATTERN = %r{\Ahttps?://(?:www\.)?flickr\.com/photos/[^/]+/(\d+)}
11
+
12
+ def self.build info:, sizes:, exif: nil
13
+ case info.media.to_s
14
+ when 'video' then Video.new(info: info, sizes: sizes, exif: exif)
15
+ else Photo.new(info: info, sizes: sizes, exif: exif)
16
+ end
17
+ end
18
+
19
+ def self.id_from_url url
20
+ match = url.match FLICKR_URL_PATTERN
21
+ match&.captures&.first
22
+ end
23
+
24
+ def self.file_path_from_list_item item, archive_path:
25
+ id = item.id
26
+ title = item.title.to_s
27
+ slug = title.slugify
28
+ basename = [id, slug.empty? ? nil : slug].compact.join('_')
29
+ ext = item.media.to_s == 'video' ? 'mp4' : item.originalformat.to_s
30
+ date = compute_date_from_list_item(item)
31
+ folder = date.strftime '%Y/%m/%d'
32
+
33
+ File.join archive_path, folder, "#{basename}.#{ext}"
34
+ end
35
+
36
+ def self.compute_date_from_list_item item
37
+ if item.datetakenunknown.to_i.zero?
38
+ Date.parse item.datetaken
39
+ else
40
+ Time.at(item.dateupload.to_i).to_date
41
+ end
42
+ end
43
+
44
+ attr_reader :camera,
45
+ :description,
46
+ :exif,
47
+ :extension,
48
+ :id,
49
+ :license,
50
+ :location,
51
+ :media,
52
+ :owner,
53
+ :tags,
54
+ :title,
55
+ :views,
56
+ :visibility
57
+
58
+ def initialize info:, sizes:, exif: nil
59
+ @id = info.id
60
+ @description = info.description.to_s
61
+ @license = License.new(info.license)
62
+ @media = info.media.to_s
63
+ @tags = extract_tags info
64
+ @title = info.title.to_s
65
+ @views = info.views.to_s
66
+ @visibility = extract_visibility info
67
+ @owner = extract_owner info
68
+
69
+ @dates = info.dates
70
+ @location = extract_location info
71
+ @sizes = sizes
72
+ @urls = extract_urls info
73
+ @camera = exif&.camera.to_s if exif
74
+ @exif = extract_exif exif
75
+ end
76
+
77
+ def basename
78
+ [id, slug].compact.join '_'
79
+ end
80
+
81
+ def date_taken
82
+ if @dates.takenunknown.to_i.zero?
83
+ Date.parse @dates.taken
84
+ else
85
+ Time.at(@dates.posted.to_i).to_date
86
+ end
87
+ end
88
+
89
+ def folder_path
90
+ date_taken.strftime '%Y/%m/%d'
91
+ end
92
+
93
+ def slug
94
+ s = title.slugify
95
+ s.empty? ? nil : s
96
+ end
97
+
98
+ def to_h
99
+ {
100
+ camera: camera,
101
+ dates: {
102
+ lastupdate: @dates.respond_to?(:lastupdate) ? @dates.lastupdate.to_s : '',
103
+ posted: @dates.posted.to_s,
104
+ taken: @dates.taken.to_s,
105
+ takenunknown: @dates.takenunknown.to_i
106
+ },
107
+ description: description,
108
+ exif: exif,
109
+ extension: extension,
110
+ id: id,
111
+ license: license.to_h,
112
+ location: location,
113
+ media: media,
114
+ original_url: original_url,
115
+ owner: owner,
116
+ sizes: extract_sizes,
117
+ tags: tags,
118
+ title: title,
119
+ urls: @urls,
120
+ views: views,
121
+ visibility: visibility
122
+ }
123
+ end
124
+
125
+ def write archive_path:, overwrite: false
126
+ dir = post_dir archive_path
127
+ file_path = File.join dir, "#{basename}.#{extension}"
128
+ existed = File.exist? file_path
129
+
130
+ return :skipped if existed && !overwrite
131
+
132
+ FileUtils.mkdir_p dir
133
+ download archive_path: archive_path
134
+ write_json archive_path: archive_path
135
+ write_yaml archive_path: archive_path
136
+
137
+ existed ? :overwritten : :created
138
+ end
139
+
140
+ def write_json archive_path:
141
+ dir = post_dir archive_path
142
+ json = JSON.pretty_generate to_h
143
+
144
+ FileUtils.mkdir_p dir
145
+ File.write File.join(dir, "#{basename}.json"), json
146
+ end
147
+
148
+ def write_yaml archive_path:
149
+ dir = post_dir archive_path
150
+ hash = deep_stringify_keys to_h
151
+ yaml = YAML.dump hash
152
+
153
+ FileUtils.mkdir_p dir
154
+ File.write File.join(dir, "#{basename}.yaml"), yaml
155
+ end
156
+
157
+ private
158
+
159
+ def deep_stringify_keys obj
160
+ case obj
161
+ when Hash then obj.transform_keys(&:to_s).transform_values { deep_stringify_keys it }
162
+ when Array then obj.map { deep_stringify_keys it }
163
+ else obj
164
+ end
165
+ end
166
+
167
+ def extract_exif exif_response
168
+ return [] unless exif_response
169
+ return [] unless exif_response.respond_to?(:exif)
170
+
171
+ exif_response.exif.map do |tag|
172
+ {
173
+ clean: tag.respond_to?(:clean) ? tag.clean&.to_s : nil,
174
+ label: tag.label.to_s,
175
+ raw: tag.raw.to_s,
176
+ tag: tag.tag.to_s,
177
+ tagspace: tag.tagspace.to_s
178
+ }
179
+ end
180
+ end
181
+
182
+ def extract_location info
183
+ return nil unless info.respond_to?(:location)
184
+
185
+ loc = info.location
186
+
187
+ {
188
+ accuracy: loc.accuracy.to_s,
189
+ context: loc.context.to_s,
190
+ country: loc.country.to_s,
191
+ county: loc.county.to_s,
192
+ latitude: loc.latitude.to_s,
193
+ locality: loc.locality.to_s,
194
+ longitude: loc.longitude.to_s,
195
+ region: loc.region.to_s
196
+ }
197
+ end
198
+
199
+ def extract_owner info
200
+ return {} unless info.respond_to?(:owner)
201
+
202
+ o = info.owner
203
+
204
+ {
205
+ nsid: o.nsid.to_s,
206
+ realname: o.realname.to_s,
207
+ username: o.username.to_s
208
+ }
209
+ end
210
+
211
+ def extract_sizes
212
+ @sizes.map do |size|
213
+ {
214
+ height: size.height.to_i,
215
+ label: size.label.to_s,
216
+ source: size.source.to_s,
217
+ width: size.width.to_i
218
+ }
219
+ end
220
+ end
221
+
222
+ def extract_tags info
223
+ return [] unless info.tags.respond_to?(:tag)
224
+
225
+ info.tags.tag.map(&:_content)
226
+ end
227
+
228
+ def extract_urls info
229
+ return {} unless info.respond_to?(:urls)
230
+ return {} unless info.urls.respond_to?(:url)
231
+
232
+ info.urls.url.to_h do |url|
233
+ [url.type.to_s, url.to_s]
234
+ end
235
+ end
236
+
237
+ def extract_visibility info
238
+ return {} unless info.respond_to?(:visibility)
239
+
240
+ vis = info.visibility
241
+
242
+ {
243
+ isfamily: vis.isfamily.to_i,
244
+ isfriend: vis.isfriend.to_i,
245
+ ispublic: vis.ispublic.to_i
246
+ }
247
+ end
248
+
249
+ def post_dir archive_path
250
+ File.join archive_path, folder_path
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,177 @@
1
+ require 'down'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Flickarr
7
+ class Profile
8
+ DEFAULT_AVATAR_URL = 'https://www.flickr.com/images/buddyicon.gif'.freeze
9
+ PROFILE_URL_PATTERN = %r{\Ahttps?://(?:www\.)?flickr\.com/(?:photos|people)/([^/]+)/?\z}
10
+
11
+ def self.matches_url? url
12
+ PROFILE_URL_PATTERN.match?(url.to_s)
13
+ end
14
+
15
+ attr_reader :city,
16
+ :country,
17
+ :description,
18
+ :email,
19
+ :facebook,
20
+ :first_name,
21
+ :hometown,
22
+ :iconfarm,
23
+ :iconserver,
24
+ :instagram,
25
+ :ispro,
26
+ :join_date,
27
+ :last_name,
28
+ :location,
29
+ :nsid,
30
+ :occupation,
31
+ :path_alias,
32
+ :photos_first_date,
33
+ :photos_first_date_taken,
34
+ :photos_count,
35
+ :photosurl,
36
+ :pinterest,
37
+ :profileurl,
38
+ :realname,
39
+ :timezone,
40
+ :tumblr,
41
+ :twitter,
42
+ :upload_count,
43
+ :username,
44
+ :website
45
+
46
+ def initialize person:, profile: nil
47
+ @description = person.description.to_s
48
+ @iconfarm = person.iconfarm
49
+ @iconserver = person.iconserver.to_s
50
+ @ispro = person.ispro
51
+ @location = person.location.to_s
52
+ @nsid = person.nsid
53
+ @path_alias = person.path_alias
54
+ @photosurl = person.photosurl.to_s
55
+ @profileurl = person.profileurl.to_s
56
+ @realname = person.realname.to_s
57
+ @timezone = { label: person.timezone.label.to_s, offset: person.timezone.offset.to_s }
58
+ @upload_count = person.respond_to?(:upload_count) ? person.upload_count.to_i : nil
59
+ @username = person.username.to_s
60
+
61
+ extract_photos person
62
+
63
+ return unless profile
64
+
65
+ @city = profile.city.to_s
66
+ @country = profile.country.to_s
67
+ @email = profile.email.to_s
68
+ @facebook = profile.facebook.to_s
69
+ @first_name = profile.first_name.to_s
70
+ @hometown = profile.hometown.to_s
71
+ @instagram = profile.instagram.to_s
72
+ @join_date = profile.join_date.to_s
73
+ @last_name = profile.last_name.to_s
74
+ @occupation = profile.occupation.to_s
75
+ @pinterest = profile.pinterest.to_s
76
+ @tumblr = profile.tumblr.to_s
77
+ @twitter = profile.twitter.to_s
78
+ @website = profile.website.to_s
79
+ end
80
+
81
+ def avatar_url
82
+ if iconserver == '0' || iconfarm.zero?
83
+ DEFAULT_AVATAR_URL
84
+ else
85
+ "https://farm#{iconfarm}.staticflickr.com/#{iconserver}/buddyicons/#{nsid}_r.jpg"
86
+ end
87
+ end
88
+
89
+ def download_avatar archive_path:
90
+ dir = profile_dir archive_path
91
+ ext = File.extname avatar_url
92
+ dest = File.join dir, "avatar#{ext}"
93
+
94
+ FileUtils.mkdir_p dir
95
+ Down.download avatar_url, destination: dest
96
+ end
97
+
98
+ def to_h
99
+ {
100
+ avatar_url: avatar_url,
101
+ city: city,
102
+ country: country,
103
+ description: description,
104
+ email: email,
105
+ facebook: facebook,
106
+ first_name: first_name,
107
+ hometown: hometown,
108
+ iconfarm: iconfarm,
109
+ iconserver: iconserver,
110
+ instagram: instagram,
111
+ ispro: ispro,
112
+ join_date: join_date,
113
+ last_name: last_name,
114
+ location: location,
115
+ nsid: nsid,
116
+ occupation: occupation,
117
+ path_alias: path_alias,
118
+ photos_count: photos_count,
119
+ photos_first_date: photos_first_date,
120
+ photos_first_date_taken: photos_first_date_taken,
121
+ photosurl: photosurl,
122
+ pinterest: pinterest,
123
+ profileurl: profileurl,
124
+ realname: realname,
125
+ timezone: timezone,
126
+ tumblr: tumblr,
127
+ twitter: twitter,
128
+ upload_count: upload_count,
129
+ username: username,
130
+ website: website
131
+ }
132
+ end
133
+
134
+ def write archive_path:, overwrite: false
135
+ dir = profile_dir archive_path
136
+ json_path = File.join dir, 'profile.json'
137
+ existed = File.exist? json_path
138
+
139
+ return :skipped if existed && !overwrite
140
+
141
+ FileUtils.mkdir_p dir
142
+ write_json dir: dir
143
+ write_yaml dir: dir
144
+ download_avatar archive_path: archive_path
145
+
146
+ existed ? :overwritten : :created
147
+ end
148
+
149
+ def write_json dir:
150
+ FileUtils.mkdir_p dir
151
+ json = JSON.pretty_generate to_h
152
+ File.write File.join(dir, 'profile.json'), json
153
+ end
154
+
155
+ def write_yaml dir:
156
+ FileUtils.mkdir_p dir
157
+ hash = to_h.transform_keys(&:to_s)
158
+ yaml = YAML.dump hash
159
+ File.write File.join(dir, 'profile.yaml'), yaml
160
+ end
161
+
162
+ private
163
+
164
+ def extract_photos person
165
+ return unless person.respond_to?(:photos)
166
+
167
+ photos = person.photos
168
+ @photos_count = photos.respond_to?(:count) ? photos.count.to_i : nil
169
+ @photos_first_date = photos.respond_to?(:firstdate) ? photos.firstdate.to_s : nil
170
+ @photos_first_date_taken = photos.respond_to?(:firstdatetaken) ? photos.firstdatetaken.to_s : nil
171
+ end
172
+
173
+ def profile_dir archive_path
174
+ File.join archive_path, 'Profile'
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,24 @@
1
+ module Flickarr
2
+ class RateLimiter
3
+ def initialize interval: 1.0
4
+ @interval = interval
5
+ @last_request_at = nil
6
+ end
7
+
8
+ def wait
9
+ return unless @last_request_at
10
+
11
+ elapsed = Time.now - @last_request_at
12
+ remaining = @interval - elapsed
13
+
14
+ sleep remaining if remaining.positive?
15
+ end
16
+
17
+ def track
18
+ wait
19
+ result = yield
20
+ @last_request_at = Time.now
21
+ result
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module Flickarr
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,65 @@
1
+ require 'http'
2
+
3
+ module Flickarr
4
+ class Video < Post
5
+ # Preferred video sizes, largest to smallest
6
+ VIDEO_SIZE_PRIORITY = ['Video Original', '1080p', '720p', '700', '360p', '288p', 'iphone_wifi'].freeze
7
+
8
+ def initialize info:, sizes:, exif: nil
9
+ super
10
+ @extension = 'mp4'
11
+ end
12
+
13
+ def download archive_path:
14
+ dir = post_dir archive_path
15
+ dest = File.join dir, "#{basename}.#{extension}"
16
+ url = resolve_download_url
17
+
18
+ FileUtils.mkdir_p dir
19
+ Down.download url, destination: dest
20
+ download_poster_frame dir: dir
21
+ end
22
+
23
+ def original_url
24
+ video_original = @sizes.find { it.label == 'Video Original' }
25
+ photo_original = @sizes.find { it.label == 'Original' }
26
+ size = video_original || photo_original || @sizes.last
27
+
28
+ size.source
29
+ end
30
+
31
+ private
32
+
33
+ def download_poster_frame dir:
34
+ poster = @sizes.find { it.label == 'Original' && it.media == 'photo' }
35
+ return unless poster
36
+
37
+ ext = File.extname(poster.source)
38
+ dest = File.join dir, "#{basename}#{ext}"
39
+
40
+ Down.download poster.source, destination: dest
41
+ end
42
+
43
+ def resolve_download_url
44
+ video_sizes = VIDEO_SIZE_PRIORITY.filter_map do |label|
45
+ @sizes.find { it.label == label && it.media == 'video' }
46
+ end
47
+
48
+ video_sizes.each do |size|
49
+ url = resolve_redirect(size.source)
50
+ response = HTTP.head(url)
51
+
52
+ return url if response.status.success?
53
+ end
54
+
55
+ # Fall back to original_url if nothing worked
56
+ resolve_redirect original_url
57
+ end
58
+
59
+ def resolve_redirect url
60
+ response = HTTP.head(url)
61
+
62
+ response.status.redirect? ? response.headers['Location'] : url
63
+ end
64
+ end
65
+ end
data/lib/flickarr.rb ADDED
@@ -0,0 +1,17 @@
1
+ require_relative 'flickarr/version'
2
+ require_relative 'flickarr/errors'
3
+ require_relative 'flickarr/config'
4
+ require_relative 'flickarr/auth'
5
+ require_relative 'flickarr/client'
6
+ require_relative 'flickarr/cli'
7
+ require_relative 'flickarr/collection'
8
+ require_relative 'flickarr/license'
9
+ require_relative 'flickarr/post'
10
+ require_relative 'flickarr/photo'
11
+ require_relative 'flickarr/photo_set'
12
+ require_relative 'flickarr/profile'
13
+ require_relative 'flickarr/rate_limiter'
14
+ require_relative 'flickarr/video'
15
+
16
+ module Flickarr
17
+ end
data/sig/flickarr.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Flickarr
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flickarr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shane Becker
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cgi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: down
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: flickr
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: http
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: slugify
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.0'
82
+ description: Export an archive of your Flickr library — photos, videos, metadata,
83
+ tags, albums, collections, and profile.
84
+ email:
85
+ - veganstraightedge@gmail.com
86
+ executables:
87
+ - flickarr
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".rubocop_todo.yml"
92
+ - CHANGELOG.md
93
+ - CODE_OF_CONDUCT.md
94
+ - HELP.txt
95
+ - HOWTO.md
96
+ - LICENSE.txt
97
+ - README.md
98
+ - Rakefile
99
+ - TODO.md
100
+ - exe/flickarr
101
+ - lib/flickarr.rb
102
+ - lib/flickarr/auth.rb
103
+ - lib/flickarr/cli.rb
104
+ - lib/flickarr/client.rb
105
+ - lib/flickarr/client/photo_query.rb
106
+ - lib/flickarr/client/profile_query.rb
107
+ - lib/flickarr/collection.rb
108
+ - lib/flickarr/config.rb
109
+ - lib/flickarr/errors.rb
110
+ - lib/flickarr/license.rb
111
+ - lib/flickarr/photo.rb
112
+ - lib/flickarr/photo_set.rb
113
+ - lib/flickarr/post.rb
114
+ - lib/flickarr/profile.rb
115
+ - lib/flickarr/rate_limiter.rb
116
+ - lib/flickarr/version.rb
117
+ - lib/flickarr/video.rb
118
+ - sig/flickarr.rbs
119
+ homepage: https://github.com/veganstraightedge/flickarr
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ homepage_uri: https://github.com/veganstraightedge/flickarr
124
+ source_code_uri: https://github.com/veganstraightedge/flickarr
125
+ changelog_uri: https://github.com/veganstraightedge/flickarr/blob/main/CHANGELOG.md
126
+ rubygems_mfa_required: 'true'
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 4.0.0
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 4.0.8
142
+ specification_version: 4
143
+ summary: Export your Flickr library archive
144
+ test_files: []