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.
- checksums.yaml +7 -0
- data/.rubocop_todo.yml +13 -0
- data/CHANGELOG.md +42 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/HELP.txt +50 -0
- data/HOWTO.md +260 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/Rakefile +10 -0
- data/TODO.md +6 -0
- data/exe/flickarr +5 -0
- data/lib/flickarr/auth.rb +43 -0
- data/lib/flickarr/cli.rb +692 -0
- data/lib/flickarr/client/photo_query.rb +23 -0
- data/lib/flickarr/client/profile_query.rb +19 -0
- data/lib/flickarr/client.rb +94 -0
- data/lib/flickarr/collection.rb +103 -0
- data/lib/flickarr/config.rb +88 -0
- data/lib/flickarr/errors.rb +7 -0
- data/lib/flickarr/license.rb +34 -0
- data/lib/flickarr/photo.rb +23 -0
- data/lib/flickarr/photo_set.rb +139 -0
- data/lib/flickarr/post.rb +253 -0
- data/lib/flickarr/profile.rb +177 -0
- data/lib/flickarr/rate_limiter.rb +24 -0
- data/lib/flickarr/version.rb +3 -0
- data/lib/flickarr/video.rb +65 -0
- data/lib/flickarr.rb +17 -0
- data/sig/flickarr.rbs +4 -0
- metadata +144 -0
data/lib/flickarr/cli.rb
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
require 'optparse'
|
|
2
|
+
|
|
3
|
+
module Flickarr
|
|
4
|
+
class CLI
|
|
5
|
+
DEFAULT_CONFIG_PATH = File.join(Dir.home, '.flickarr', 'config.yml').freeze
|
|
6
|
+
VALID_CONFIG_KEYS = %i[access_secret access_token api_key last_page_photos last_page_posts last_page_videos
|
|
7
|
+
library_path shared_secret total_collections total_photos total_sets total_videos
|
|
8
|
+
user_nsid username].freeze
|
|
9
|
+
|
|
10
|
+
def initialize args, config_path: DEFAULT_CONFIG_PATH
|
|
11
|
+
@config_path = config_path
|
|
12
|
+
@limit = nil
|
|
13
|
+
@overwrite = false
|
|
14
|
+
|
|
15
|
+
if args.empty? || %w[-h --help help].include?(args.first)
|
|
16
|
+
@args = args
|
|
17
|
+
else
|
|
18
|
+
@parser = build_parser
|
|
19
|
+
@args = @parser.parse(args)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
command = @args.shift
|
|
25
|
+
|
|
26
|
+
case command
|
|
27
|
+
when 'auth' then run_auth
|
|
28
|
+
when 'config' then run_config
|
|
29
|
+
when 'config:set' then run_config_set
|
|
30
|
+
when 'errors' then run_errors
|
|
31
|
+
when 'export', 'export:posts' then run_export_or_post
|
|
32
|
+
when 'export:albums', 'export:sets', 'export:set' then run_export_sets_or_one
|
|
33
|
+
when 'export:collections' then run_export_collections_or_one
|
|
34
|
+
when 'export:photo', 'export:video' then run_export_post
|
|
35
|
+
when 'export:photos' then run_export_posts(media: 'photos')
|
|
36
|
+
when 'export:profile' then run_export_profile
|
|
37
|
+
when 'export:videos' then run_export_posts(media: 'videos')
|
|
38
|
+
when 'init' then run_init
|
|
39
|
+
when 'open' then run_open
|
|
40
|
+
when 'path' then run_path
|
|
41
|
+
when 'status' then run_status
|
|
42
|
+
else print_help
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_parser
|
|
49
|
+
OptionParser.new do |opts|
|
|
50
|
+
opts.banner = 'Usage: flickarr <command> [options]'
|
|
51
|
+
|
|
52
|
+
opts.on('--limit N', Integer, 'Stop after N items') do |n|
|
|
53
|
+
@limit = n
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on('--overwrite', 'Re-download and overwrite existing files') do
|
|
57
|
+
@overwrite = true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_errors
|
|
63
|
+
config = Config.load(@config_path)
|
|
64
|
+
archive = config.archive_path
|
|
65
|
+
|
|
66
|
+
unless archive
|
|
67
|
+
warn 'Error: No archive path configured. Run `flickarr auth` first.'
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts File.join(archive, '_errors.log')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_export_collections_or_one
|
|
75
|
+
if @args.first && Collection.id_from_url(@args.first)
|
|
76
|
+
run_export_single_collection
|
|
77
|
+
else
|
|
78
|
+
run_export_collections
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run_export_collections
|
|
83
|
+
config = Config.load(@config_path)
|
|
84
|
+
|
|
85
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
86
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
client = Client.new(config)
|
|
91
|
+
archive = config.archive_path
|
|
92
|
+
tree = client.collections(user_id: config.user_nsid)
|
|
93
|
+
count = 0
|
|
94
|
+
|
|
95
|
+
total = tree.respond_to?(:count) ? tree.count : 0
|
|
96
|
+
download_count = 0
|
|
97
|
+
interrupted = false
|
|
98
|
+
trap('INT') { interrupted = true }
|
|
99
|
+
|
|
100
|
+
tree.each do |collection_data|
|
|
101
|
+
break if interrupted
|
|
102
|
+
|
|
103
|
+
count += 1
|
|
104
|
+
collection = Collection.new(collection_data)
|
|
105
|
+
status = collection.write(archive_path: archive, overwrite: @overwrite)
|
|
106
|
+
path = File.join archive, 'Collections', collection.dirname
|
|
107
|
+
|
|
108
|
+
puts "#{collection.title} (#{count}/#{total})"
|
|
109
|
+
case status
|
|
110
|
+
when :created
|
|
111
|
+
puts " Downloaded to #{path}"
|
|
112
|
+
download_count += 1
|
|
113
|
+
when :overwritten
|
|
114
|
+
puts " Re-downloaded to #{path}"
|
|
115
|
+
download_count += 1
|
|
116
|
+
when :skipped
|
|
117
|
+
puts " Skipped at #{path}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
break if @limit && download_count >= @limit
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
puts "\nInterrupted." if interrupted
|
|
124
|
+
puts "Done. #{count} collections processed."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_export_single_collection
|
|
128
|
+
url = @args.shift
|
|
129
|
+
collection_id = Collection.id_from_url(url)
|
|
130
|
+
config = Config.load(@config_path)
|
|
131
|
+
archive = config.archive_path
|
|
132
|
+
|
|
133
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
134
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
client = Client.new(config)
|
|
139
|
+
tree = client.collections(user_id: config.user_nsid)
|
|
140
|
+
match = tree.find { it.id.include?(collection_id) }
|
|
141
|
+
|
|
142
|
+
unless match
|
|
143
|
+
warn "Error: Collection #{collection_id} not found."
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
collection = Collection.new(match)
|
|
148
|
+
status = collection.write(archive_path: archive, overwrite: @overwrite)
|
|
149
|
+
path = File.join archive, 'Collections', collection.dirname
|
|
150
|
+
|
|
151
|
+
puts collection.title
|
|
152
|
+
case status
|
|
153
|
+
when :created then puts " Downloaded to #{path}"
|
|
154
|
+
when :overwritten then puts " Re-downloaded to #{path}"
|
|
155
|
+
when :skipped then puts " Skipped at #{path}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_export_sets_or_one
|
|
160
|
+
if @args.first && PhotoSet.id_from_url(@args.first)
|
|
161
|
+
run_export_single_set
|
|
162
|
+
else
|
|
163
|
+
run_export_sets
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def run_export_sets
|
|
168
|
+
config = Config.load(@config_path)
|
|
169
|
+
|
|
170
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
171
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
client = Client.new(config)
|
|
176
|
+
archive = config.archive_path
|
|
177
|
+
sets = client.sets(user_id: config.user_nsid)
|
|
178
|
+
count = 0
|
|
179
|
+
total = sets.respond_to?(:total) ? sets.total.to_i : 0
|
|
180
|
+
|
|
181
|
+
download_count = 0
|
|
182
|
+
interrupted = false
|
|
183
|
+
trap('INT') { interrupted = true }
|
|
184
|
+
|
|
185
|
+
sets.each do |set_data|
|
|
186
|
+
break if interrupted
|
|
187
|
+
|
|
188
|
+
count += 1
|
|
189
|
+
|
|
190
|
+
photos_response = client.set_photos(photoset_id: set_data.id, user_id: config.user_nsid)
|
|
191
|
+
photo_items = photos_response.respond_to?(:photo) ? photos_response.photo.to_a : []
|
|
192
|
+
|
|
193
|
+
photo_set = PhotoSet.new(set: set_data, photo_items: photo_items)
|
|
194
|
+
status = photo_set.write(archive_path: archive, overwrite: @overwrite)
|
|
195
|
+
path = File.join archive, 'Sets', photo_set.dirname
|
|
196
|
+
|
|
197
|
+
puts "#{photo_set.title} (#{count}/#{total})"
|
|
198
|
+
case status
|
|
199
|
+
when :created
|
|
200
|
+
puts " Downloaded to #{path}"
|
|
201
|
+
download_count += 1
|
|
202
|
+
when :overwritten
|
|
203
|
+
puts " Re-downloaded to #{path}"
|
|
204
|
+
download_count += 1
|
|
205
|
+
when :skipped
|
|
206
|
+
puts " Skipped at #{path}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
break if @limit && download_count >= @limit
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
puts "\nInterrupted." if interrupted
|
|
213
|
+
puts "Done. #{count} sets processed."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def run_export_single_set
|
|
217
|
+
url = @args.shift
|
|
218
|
+
set_id = PhotoSet.id_from_url(url)
|
|
219
|
+
config = Config.load(@config_path)
|
|
220
|
+
|
|
221
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
222
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
client = Client.new(config)
|
|
227
|
+
archive = config.archive_path
|
|
228
|
+
|
|
229
|
+
begin
|
|
230
|
+
set_data = client.flickr.photosets.getInfo(photoset_id: set_id, user_id: config.user_nsid)
|
|
231
|
+
photos_response = client.set_photos(photoset_id: set_id, user_id: config.user_nsid)
|
|
232
|
+
rescue Flickr::FailedResponse => e
|
|
233
|
+
warn "Error: #{e.message}"
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
photo_items = photos_response.respond_to?(:photo) ? photos_response.photo.to_a : []
|
|
238
|
+
photo_set = PhotoSet.new(set: set_data, photo_items: photo_items)
|
|
239
|
+
status = photo_set.write(archive_path: archive, overwrite: @overwrite)
|
|
240
|
+
path = File.join archive, 'Sets', photo_set.dirname
|
|
241
|
+
|
|
242
|
+
puts photo_set.title
|
|
243
|
+
case status
|
|
244
|
+
when :created then puts " Downloaded to #{path}"
|
|
245
|
+
when :overwritten then puts " Re-downloaded to #{path}"
|
|
246
|
+
when :skipped then puts " Skipped at #{path}"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def run_open
|
|
251
|
+
config = Config.load(@config_path)
|
|
252
|
+
archive = config.archive_path
|
|
253
|
+
|
|
254
|
+
unless archive && Dir.exist?(archive)
|
|
255
|
+
puts 'No archive found. Run `flickarr init` and `flickarr auth` first.'
|
|
256
|
+
return
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
system 'open', archive
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def run_path
|
|
263
|
+
config = Config.load(@config_path)
|
|
264
|
+
archive = config.archive_path
|
|
265
|
+
|
|
266
|
+
if archive
|
|
267
|
+
puts archive
|
|
268
|
+
else
|
|
269
|
+
warn 'Error: No archive path configured. Run `flickarr auth` first.'
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def run_init
|
|
274
|
+
if File.exist?(@config_path)
|
|
275
|
+
puts "Config already exists at #{@config_path}"
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
library_path = @args.shift
|
|
280
|
+
config = Config.new
|
|
281
|
+
config.library_path = File.expand_path(library_path) if library_path
|
|
282
|
+
config.save @config_path
|
|
283
|
+
puts "Initialized Flickarr config at #{@config_path}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def run_status
|
|
287
|
+
config = Config.load(@config_path)
|
|
288
|
+
archive = config.archive_path
|
|
289
|
+
|
|
290
|
+
unless archive && Dir.exist?(archive)
|
|
291
|
+
puts 'No archive found. Run `flickarr init` and `flickarr auth` first.'
|
|
292
|
+
return
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
fetch_and_cache_totals(config) if @overwrite || !config.total_photos
|
|
296
|
+
|
|
297
|
+
profile_exists = File.exist?(File.join(archive, 'Profile', 'profile.json'))
|
|
298
|
+
photo_count = count_media_files(archive, %w[jpg jpeg png gif tiff])
|
|
299
|
+
video_count = count_media_files(archive, %w[mp4])
|
|
300
|
+
set_count = count_subdirs(File.join(archive, 'Sets'))
|
|
301
|
+
collection_count = count_subdirs(File.join(archive, 'Collections'))
|
|
302
|
+
disk_usage = human_size(dir_size(archive))
|
|
303
|
+
|
|
304
|
+
rows = [
|
|
305
|
+
['Archive', archive],
|
|
306
|
+
['Profile', profile_exists ? 'Downloaded' : 'Not downloaded'],
|
|
307
|
+
['Photos', format_count(photo_count, config.total_photos)],
|
|
308
|
+
['Videos', format_count(video_count, config.total_videos)],
|
|
309
|
+
['Sets', format_count(set_count, config.total_sets)],
|
|
310
|
+
['Collections', format_count(collection_count, config.total_collections)],
|
|
311
|
+
['Disk usage', disk_usage]
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
max_width = rows.map { it.first.length }.max + 1
|
|
315
|
+
rows.each do |label, value|
|
|
316
|
+
puts "#{"#{label}:".ljust(max_width)} #{value}"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def fetch_and_cache_totals config
|
|
321
|
+
return unless config.access_token && config.access_secret && config.user_nsid
|
|
322
|
+
|
|
323
|
+
client = Client.new(config)
|
|
324
|
+
|
|
325
|
+
photos_response = client.photos(user_id: config.user_nsid, per_page: 1)
|
|
326
|
+
config.total_photos = photos_response.total.to_i
|
|
327
|
+
|
|
328
|
+
videos_response = client.flickr.photos.search(user_id: config.user_nsid, media: 'videos', per_page: 1)
|
|
329
|
+
config.total_videos = videos_response.total.to_i
|
|
330
|
+
|
|
331
|
+
sets_response = client.sets(user_id: config.user_nsid)
|
|
332
|
+
config.total_sets = sets_response.respond_to?(:total) ? sets_response.total.to_i : 0
|
|
333
|
+
|
|
334
|
+
collections_response = client.collections(user_id: config.user_nsid)
|
|
335
|
+
config.total_collections = collections_response.respond_to?(:count) ? collections_response.count : 0
|
|
336
|
+
|
|
337
|
+
config.save @config_path
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def format_count local, total
|
|
341
|
+
total ? "#{local} / #{total}" : local.to_s
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def post_exists_on_disk? archive:, post_id:
|
|
345
|
+
Dir.glob(File.join(archive, '**', "#{post_id}_*")).any? ||
|
|
346
|
+
Dir.glob(File.join(archive, '**', "#{post_id}.*")).any?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def count_media_files archive, extensions
|
|
350
|
+
pattern = File.join(archive, '**', "*.{#{extensions.join(',')}}")
|
|
351
|
+
Dir.glob(pattern).count do |path|
|
|
352
|
+
!path.include?('/Profile/') && !path.include?('/Sets/') && !path.include?('/Collections/')
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def count_subdirs path
|
|
357
|
+
return 0 unless Dir.exist?(path)
|
|
358
|
+
|
|
359
|
+
Dir.children(path).count { File.directory?(File.join(path, it)) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def dir_size path
|
|
363
|
+
Dir.glob(File.join(path, '**', '*')).select { File.file?(it) }.sum { File.size(it) }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def human_size bytes
|
|
367
|
+
units = %w[B KB MB GB TB]
|
|
368
|
+
unit = 0
|
|
369
|
+
|
|
370
|
+
size = bytes.to_f
|
|
371
|
+
while size >= 1024 && unit < units.length - 1
|
|
372
|
+
size /= 1024
|
|
373
|
+
unit += 1
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
format('%<size>.1f %<unit>s', size: size, unit: units[unit])
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def run_export_or_post
|
|
380
|
+
url = @args.first
|
|
381
|
+
|
|
382
|
+
if Collection.id_from_url(url.to_s)
|
|
383
|
+
run_export_single_collection
|
|
384
|
+
elsif PhotoSet.id_from_url(url.to_s)
|
|
385
|
+
run_export_single_set
|
|
386
|
+
elsif Profile.matches_url?(url.to_s)
|
|
387
|
+
@args.shift
|
|
388
|
+
run_export_profile
|
|
389
|
+
elsif Post.id_from_url(url.to_s)
|
|
390
|
+
run_export_post
|
|
391
|
+
else
|
|
392
|
+
run_export_posts
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def run_export_post
|
|
397
|
+
url = @args.shift
|
|
398
|
+
post_id = Post.id_from_url(url.to_s)
|
|
399
|
+
|
|
400
|
+
unless post_id
|
|
401
|
+
warn 'Error: Could not extract post ID from URL.'
|
|
402
|
+
return
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
config = Config.load(@config_path)
|
|
406
|
+
archive = config.archive_path
|
|
407
|
+
|
|
408
|
+
if !@overwrite && archive && post_exists_on_disk?(archive: archive, post_id: post_id)
|
|
409
|
+
puts "Skipped #{post_id} (already exists)"
|
|
410
|
+
return
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
unless config.access_token && config.access_secret
|
|
414
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
415
|
+
return
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
client = Client.new(config)
|
|
419
|
+
query = client.photo(id: post_id)
|
|
420
|
+
|
|
421
|
+
begin
|
|
422
|
+
post = Post.build(info: query.info, sizes: query.sizes.size, exif: query.exif)
|
|
423
|
+
rescue Flickr::FailedResponse => e
|
|
424
|
+
warn "Error: #{e.message}"
|
|
425
|
+
return
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
status = post.write(archive_path: archive, overwrite: @overwrite)
|
|
429
|
+
path = File.join archive, post.folder_path
|
|
430
|
+
|
|
431
|
+
case status
|
|
432
|
+
when :created then puts "Downloaded #{post.media} #{post_id} to #{path}"
|
|
433
|
+
when :overwritten then puts "Re-downloaded #{post.media} #{post_id} to #{path}"
|
|
434
|
+
when :skipped then puts "Skipped #{post.media} #{post_id} (already exists at #{path})"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def run_export_posts media: 'all'
|
|
439
|
+
config = Config.load(@config_path)
|
|
440
|
+
|
|
441
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
442
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
443
|
+
return
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
client = Client.new(config)
|
|
447
|
+
archive = config.archive_path
|
|
448
|
+
last_page = read_last_page(config, media)
|
|
449
|
+
start_page = last_page ? last_page + 1 : 1
|
|
450
|
+
per_page = 100
|
|
451
|
+
page = start_page
|
|
452
|
+
count = (start_page - 1) * per_page
|
|
453
|
+
run_count = 0
|
|
454
|
+
|
|
455
|
+
interrupted = false
|
|
456
|
+
trap('INT') { interrupted = true }
|
|
457
|
+
|
|
458
|
+
puts "Starting from page #{page}..." if page > 1
|
|
459
|
+
|
|
460
|
+
catch(:stop_export) do
|
|
461
|
+
loop do
|
|
462
|
+
response = fetch_posts_page(client: client, config: config, media: media, page: page)
|
|
463
|
+
total = response.total.to_i
|
|
464
|
+
total_pages = response.pages.to_i
|
|
465
|
+
|
|
466
|
+
puts "Page #{page}/#{total_pages}"
|
|
467
|
+
|
|
468
|
+
response.each do |list_post|
|
|
469
|
+
throw(:stop_export) if interrupted
|
|
470
|
+
|
|
471
|
+
count += 1
|
|
472
|
+
|
|
473
|
+
if !@overwrite && File.exist?(Post.file_path_from_list_item(list_post, archive_path: archive))
|
|
474
|
+
puts "Skipped #{list_post.media} #{list_post.id} (#{count}/#{total})"
|
|
475
|
+
else
|
|
476
|
+
export_single_post(client: client, config: config, post_id: list_post.id, count: count, total: total)
|
|
477
|
+
run_count += 1
|
|
478
|
+
throw(:stop_export) if @limit && run_count >= @limit
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
write_last_page config, media, page
|
|
483
|
+
config.save @config_path
|
|
484
|
+
|
|
485
|
+
break if page >= total_pages
|
|
486
|
+
|
|
487
|
+
page += 1
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
if interrupted
|
|
492
|
+
write_last_page config, media, page - 1
|
|
493
|
+
config.save @config_path
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
puts "\nInterrupted. Saved progress at page #{page}." if interrupted
|
|
497
|
+
puts "Reached limit of #{@limit} posts." if !interrupted && @limit && run_count >= @limit
|
|
498
|
+
puts "Done. #{run_count} posts processed this run."
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def read_last_page config, media
|
|
502
|
+
case media
|
|
503
|
+
when 'photos' then config.last_page_photos
|
|
504
|
+
when 'videos' then config.last_page_videos
|
|
505
|
+
else config.last_page_posts
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def write_last_page config, media, page
|
|
510
|
+
case media
|
|
511
|
+
when 'photos' then config.last_page_photos = page
|
|
512
|
+
when 'videos' then config.last_page_videos = page
|
|
513
|
+
else config.last_page_posts = page
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def fetch_posts_page client:, config:, media:, page:
|
|
518
|
+
case media
|
|
519
|
+
when 'photos' then client.flickr.photos.search(user_id: config.user_nsid,
|
|
520
|
+
media: 'photos',
|
|
521
|
+
page: page,
|
|
522
|
+
per_page: 100,
|
|
523
|
+
extras: Client::PHOTO_EXTRAS)
|
|
524
|
+
when 'videos' then client.flickr.photos.search(user_id: config.user_nsid,
|
|
525
|
+
media: 'videos',
|
|
526
|
+
page: page,
|
|
527
|
+
per_page: 100,
|
|
528
|
+
extras: Client::PHOTO_EXTRAS)
|
|
529
|
+
else client.photos(user_id: config.user_nsid, page: page)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def export_single_post client:, config:, post_id:, count:, total:
|
|
534
|
+
query = client.photo(id: post_id)
|
|
535
|
+
archive = config.archive_path
|
|
536
|
+
|
|
537
|
+
begin
|
|
538
|
+
post = Post.build(info: query.info, sizes: query.sizes.size, exif: query.exif)
|
|
539
|
+
status = post.write(archive_path: archive, overwrite: @overwrite)
|
|
540
|
+
rescue Flickr::FailedResponse => e
|
|
541
|
+
warn "Error on post #{post_id}: #{e.message}"
|
|
542
|
+
log_error archive: archive, post_id: post_id, username: config.username, error: e
|
|
543
|
+
return
|
|
544
|
+
rescue Down::Error => e
|
|
545
|
+
warn "Download error on post #{post_id}: #{e.message}"
|
|
546
|
+
log_error archive: archive, post_id: post_id, username: config.username, error: e
|
|
547
|
+
return
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
path = File.join archive, post.folder_path
|
|
551
|
+
|
|
552
|
+
case status
|
|
553
|
+
when :created then puts "Downloaded #{post.media} #{post_id} to #{path} (#{count}/#{total})"
|
|
554
|
+
when :overwritten then puts "Re-downloaded #{post.media} #{post_id} to #{path} (#{count}/#{total})"
|
|
555
|
+
when :skipped then puts "Skipped #{post.media} #{post_id} (#{count}/#{total})"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def log_error archive:, post_id:, username:, error:
|
|
560
|
+
log_path = File.join archive, '_errors.log'
|
|
561
|
+
FileUtils.mkdir_p File.dirname(log_path)
|
|
562
|
+
|
|
563
|
+
File.open(log_path, 'a') do |f|
|
|
564
|
+
f.puts '---'
|
|
565
|
+
f.puts "Time: #{Time.now.utc.iso8601}"
|
|
566
|
+
f.puts "Post ID: #{post_id}"
|
|
567
|
+
f.puts "URL: https://www.flickr.com/photos/#{username}/#{post_id}/"
|
|
568
|
+
f.puts "Error: #{error.class}"
|
|
569
|
+
f.puts "Message: #{error.message}"
|
|
570
|
+
f.puts
|
|
571
|
+
f.puts
|
|
572
|
+
f.puts
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def run_export_profile
|
|
577
|
+
config = Config.load(@config_path)
|
|
578
|
+
|
|
579
|
+
unless config.access_token && config.access_secret && config.user_nsid
|
|
580
|
+
warn 'Error: Not authenticated. Run `flickarr auth` first.'
|
|
581
|
+
return
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
client = Client.new(config)
|
|
585
|
+
profile_query = client.profile(user_id: config.user_nsid)
|
|
586
|
+
profile = Profile.new(person: profile_query.info, profile: profile_query.profile)
|
|
587
|
+
archive = config.archive_path
|
|
588
|
+
|
|
589
|
+
status = profile.write(archive_path: archive, overwrite: @overwrite)
|
|
590
|
+
profile_dir = File.join archive, 'Profile'
|
|
591
|
+
|
|
592
|
+
case status
|
|
593
|
+
when :created then puts "Downloaded profile to #{profile_dir}"
|
|
594
|
+
when :overwritten then puts "Re-downloaded profile to #{profile_dir}"
|
|
595
|
+
when :skipped then puts "Skipped profile (already exists at #{profile_dir})"
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def run_auth
|
|
600
|
+
config = Config.load(@config_path)
|
|
601
|
+
auth = Auth.new(config, config_path: @config_path)
|
|
602
|
+
auth.authenticate
|
|
603
|
+
rescue ConfigError => e
|
|
604
|
+
warn "Error: #{e.message}"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def run_config
|
|
608
|
+
key = @args.shift
|
|
609
|
+
|
|
610
|
+
if key
|
|
611
|
+
show_config_value(key)
|
|
612
|
+
else
|
|
613
|
+
show_config
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def show_config
|
|
618
|
+
unless File.exist?(@config_path)
|
|
619
|
+
puts "No config file found at #{@config_path}"
|
|
620
|
+
return
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
config = Config.load(@config_path)
|
|
624
|
+
print_config(config)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def show_config_value key
|
|
628
|
+
unless File.exist?(@config_path)
|
|
629
|
+
puts "No config file found at #{@config_path}"
|
|
630
|
+
return
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
config = Config.load(@config_path)
|
|
634
|
+
puts config.to_h[key.to_sym]
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def run_config_set
|
|
638
|
+
if @args.empty?
|
|
639
|
+
puts 'Usage: flickarr config:set <key>=<value> [<key>=<value> ...]'
|
|
640
|
+
return
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
pairs = @args.map { it.split('=', 2) }
|
|
644
|
+
invalid_key = pairs.map(&:first).find { !VALID_CONFIG_KEYS.include?(it.to_sym) }
|
|
645
|
+
|
|
646
|
+
if invalid_key
|
|
647
|
+
puts "Unknown config key: #{invalid_key}"
|
|
648
|
+
return
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
config = Config.load(@config_path)
|
|
652
|
+
pairs.each { |key, value| set_config_attr(config, key, value) }
|
|
653
|
+
config.save(@config_path)
|
|
654
|
+
print_config(config)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def set_config_attr config, key, value
|
|
658
|
+
case key
|
|
659
|
+
when 'access_secret' then config.access_secret = value
|
|
660
|
+
when 'access_token' then config.access_token = value
|
|
661
|
+
when 'api_key' then config.api_key = value
|
|
662
|
+
when 'last_page_photos' then config.last_page_photos = value.to_i
|
|
663
|
+
when 'last_page_posts' then config.last_page_posts = value.to_i
|
|
664
|
+
when 'last_page_videos' then config.last_page_videos = value.to_i
|
|
665
|
+
when 'library_path' then config.library_path = value
|
|
666
|
+
when 'shared_secret' then config.shared_secret = value
|
|
667
|
+
when 'total_collections' then config.total_collections = value.to_i
|
|
668
|
+
when 'total_photos' then config.total_photos = value.to_i
|
|
669
|
+
when 'total_sets' then config.total_sets = value.to_i
|
|
670
|
+
when 'total_videos' then config.total_videos = value.to_i
|
|
671
|
+
when 'user_nsid' then config.user_nsid = value
|
|
672
|
+
when 'username' then config.username = value
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def print_config config
|
|
677
|
+
hash = config.to_h
|
|
678
|
+
max_width = hash.keys.map { it.to_s.length }.max
|
|
679
|
+
|
|
680
|
+
hash.each do |key, value|
|
|
681
|
+
label = key.to_s.ljust max_width
|
|
682
|
+
puts "#{label} #{value || '(not set)'}"
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def print_help
|
|
687
|
+
help_path = File.expand_path('../../HELP.txt', __dir__)
|
|
688
|
+
puts File.read(help_path)
|
|
689
|
+
puts
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Flickarr
|
|
2
|
+
class Client
|
|
3
|
+
class PhotoQuery
|
|
4
|
+
def initialize flickr:, id:, rate_limiter:
|
|
5
|
+
@flickr = flickr
|
|
6
|
+
@id = id
|
|
7
|
+
@rate_limiter = rate_limiter
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def exif
|
|
11
|
+
@rate_limiter.track { @flickr.photos.getExif(photo_id: @id) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def info
|
|
15
|
+
@rate_limiter.track { @flickr.photos.getInfo(photo_id: @id) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sizes
|
|
19
|
+
@rate_limiter.track { @flickr.photos.getSizes(photo_id: @id) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|