git-fit 0.6.1 → 0.7.1

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,322 @@
1
+ require "thread"
2
+
3
+ module GitFit
4
+ module Sync
5
+ class Runner
6
+ POISON = Object.new.freeze
7
+ BATCH_SIZE = 5
8
+
9
+ def initialize(sources:, db:, config:, activity_filter: nil,
10
+ privacy: nil, num_workers: 3, time_budget: nil, total_timeout: nil)
11
+ @sources = sources
12
+ @db = db
13
+ @config = config
14
+ @activity_filter = activity_filter
15
+ @privacy = privacy
16
+ @num_workers = [@num_workers.to_i, 1].max
17
+ @time_budget = time_budget.is_a?(Numeric) ? time_budget.to_f : nil
18
+ @total_timeout = total_timeout.is_a?(Numeric) ? total_timeout.to_f : nil
19
+ @sync_start = Time.now
20
+ @watchdog = nil
21
+
22
+ @pending_ids = {}
23
+ @adapters = {}
24
+ @source_queue = Queue.new
25
+ @output_queue = Queue.new
26
+ @stats_lock = Mutex.new
27
+ @unfinished = 0
28
+ @completed = {}
29
+ @timed_out = false
30
+ @shutdown = false
31
+ @source_start_times = {}
32
+ @source_activated = {}
33
+ end
34
+
35
+ def run
36
+ output_thread = Thread.new { output_loop }
37
+ start_watchdog if @total_timeout
38
+ init_pending
39
+ unless @source_queue.empty?
40
+ worker_threads = @num_workers.times.map { Thread.new { worker_loop } }
41
+ worker_threads.each(&:join)
42
+ end
43
+ output_queue << POISON
44
+ output_thread.join(60) || output_thread.kill
45
+
46
+ # TODO: Dedup 需要独立设计三种语义后再实现
47
+ # A. 同源防重: upsert_activity 已通过 run_id 唯一约束天然保证
48
+ # B. 跨设备同活动: 需轨迹相似度匹配 + 时间窗口聚类
49
+ # C. 跨平台迁移复制: 需 external_id 映射表 + 数据指纹比对
50
+ # 触发时机、匹配算法、合并策略见 docs/dedup-design.md
51
+ # Dedup::Service.new(@db).run if defined?(Dedup::Service) && ...
52
+ ensure
53
+ @watchdog&.kill
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :output_queue
59
+
60
+ def init_pending
61
+ @sources.each do |source|
62
+ cfg = @config.sync_config(source)
63
+ next if cfg.nil? || cfg.empty?
64
+ @source_queue << source
65
+ end
66
+ @unfinished = @source_queue.size
67
+ end
68
+
69
+ def adapter_class(source, _cfg)
70
+ (GitFit::Sync::Base.adapters + GitFit::Sync::Base.test_adapters)
71
+ .find { |a| a.name.split("::").last.downcase == source }
72
+ end
73
+
74
+ def init_source(source)
75
+ klass = adapter_class(source, nil)
76
+ actual_key = klass&.config_key || source
77
+ cfg = @config.sync_config(actual_key)
78
+ unless klass
79
+ output_queue << { type: :banner, source:, text: "no adapter" }
80
+ clear_source(source)
81
+ return false
82
+ end
83
+
84
+ output_queue << { type: :banner, source:, text: "authenticating" }
85
+ adapter = klass.new(config: cfg, db: @db, activity_filter: @activity_filter, privacy: @privacy, time_budget: @time_budget)
86
+ unless adapter.before_call
87
+ output_queue << { type: :banner, source:, text: "before_call failed" }
88
+ clear_source(source)
89
+ return false
90
+ end
91
+
92
+ unless adapter.authenticate
93
+ output_queue << { type: :banner, source:, text: "auth failed" }
94
+ clear_source(source)
95
+ return false
96
+ end
97
+
98
+ output_queue << { type: :banner, source:, text: "fetching activity list" }
99
+ ids = adapter.pending_ids
100
+ if ids.nil? || ids.empty?
101
+ output_queue << { type: :banner, source:, text: "no new activities" }
102
+ clear_source(source)
103
+ return false
104
+ end
105
+
106
+ @adapters[source] = adapter
107
+ @pending_ids[source] = ids
108
+ @source_start_times[source] = Time.now
109
+ @source_activated[source] = true
110
+ output_queue << { type: :banner, source:, text: "#{ids.size} pending" }
111
+ true
112
+ end
113
+
114
+ def clear_source(source)
115
+ @pending_ids.delete(source)
116
+ @adapters.delete(source)
117
+ @source_start_times.delete(source)
118
+ @source_activated.delete(source)
119
+ end
120
+
121
+ def worker_loop
122
+ loop do
123
+ source = @source_queue.pop
124
+ break if source == POISON
125
+
126
+ begin
127
+ process_source(source)
128
+ rescue => e
129
+ @output_queue << { type: :error, source:, message: "Worker crashed: #{e.message}" }
130
+ @stats_lock.synchronize do
131
+ @pending_ids.delete(source)
132
+ @adapters.delete(source)
133
+ return if @completed[source]
134
+ @completed[source] = true
135
+ @unfinished -= 1
136
+ @unfinished = 0 if @unfinished < 0
137
+ if @unfinished <= 0
138
+ @shutdown = true
139
+ @num_workers.times { @source_queue << POISON }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def process_source(source)
147
+ unless init_source(source)
148
+ finish_activity(source)
149
+ return
150
+ end
151
+
152
+ total = @pending_ids[source].size
153
+ processed = 0
154
+ timed_out_local = false
155
+
156
+ loop do
157
+ activity = nil
158
+ @stats_lock.synchronize { activity = @pending_ids[source]&.shift }
159
+ break unless activity
160
+
161
+ if @stats_lock.synchronize { @shutdown }
162
+ @stats_lock.synchronize do
163
+ @pending_ids[source]&.unshift(activity) if @pending_ids[source]
164
+ end
165
+ timed_out_local = true
166
+ break
167
+ end
168
+
169
+ if source_budget_expired?(source)
170
+ @stats_lock.synchronize do
171
+ @timed_out = true
172
+ @pending_ids[source]&.unshift(activity) if @pending_ids[source]
173
+ end
174
+ timed_out_local = true
175
+ break
176
+ end
177
+
178
+ if @adapters[source].respond_to?(:skip_type?, true) && @adapters[source].send(:skip_type?, activity[:type])
179
+ next
180
+ end
181
+
182
+ begin
183
+ result = @adapters[source].process_one(activity)
184
+ processed += 1
185
+ if result
186
+ info = @adapters[source].build_progress_info(result)
187
+ @output_queue << {
188
+ type: :progress, source:, id: activity[:id],
189
+ info: info, done: processed, total: total
190
+ }
191
+ else
192
+ @output_queue << {
193
+ type: :progress, source:, id: activity[:id],
194
+ info: nil, done: processed, total: total
195
+ }
196
+ end
197
+ rescue => e
198
+ @output_queue << { type: :error, source:, message: e.message }
199
+ end
200
+
201
+ break if processed >= BATCH_SIZE
202
+ end
203
+
204
+ finish_activity(source, timed_out_local)
205
+ end
206
+
207
+ def finish_activity(source, timed_out = false)
208
+ @stats_lock.synchronize do
209
+ if timed_out
210
+ remaining = @pending_ids[source]&.size.to_i
211
+ @output_queue << { type: :timeout, source:, remaining: }
212
+ @pending_ids[source]&.clear
213
+ end
214
+
215
+ more = @pending_ids[source]&.any?
216
+ if more && !@shutdown
217
+ @source_queue << source
218
+ else
219
+ return if @completed[source]
220
+ @completed[source] = true
221
+ @unfinished -= 1
222
+ @unfinished = 0 if @unfinished < 0
223
+ if @unfinished <= 0
224
+ @shutdown = true
225
+ @num_workers.times { @source_queue << POISON }
226
+ end
227
+ end
228
+ end
229
+ end
230
+
231
+ def source_budget_expired?(source)
232
+ return false unless @time_budget&.positive?
233
+ start_time = @stats_lock.synchronize { @source_start_times[source] }
234
+ return false unless start_time
235
+ (Time.now - start_time) > @time_budget
236
+ end
237
+
238
+ def start_watchdog
239
+ @watchdog = Thread.new do
240
+ sleep @total_timeout
241
+ @stats_lock.synchronize { @shutdown = true }
242
+ @output_queue << { type: :total_timeout, seconds: @total_timeout }
243
+ @num_workers.times { @source_queue << POISON }
244
+ end
245
+ end
246
+
247
+ def output_loop
248
+ $stdout.sync = true
249
+ $stdout.puts "Runner started with #{@num_workers} workers, #{@sources.size} sources"
250
+ $stdout.flush
251
+ per_source = Hash.new { |h, k| h[k] = {} }
252
+
253
+ loop do
254
+ msg = @output_queue.pop
255
+ break if msg == POISON
256
+
257
+ s = msg[:source]
258
+ ps = per_source[s]
259
+
260
+ case msg[:type]
261
+ when :banner
262
+ text = msg[:text]
263
+ if text =~ /auth failed/
264
+ ps[:auth_failed] = true
265
+ elsif text =~ /no new activities/
266
+ ps[:up_to_date] = true
267
+ elsif text =~ /(\d+) pending/
268
+ ps[:total] = $1.to_i
269
+ end
270
+ $stdout.puts " #{s.rjust(10)} | #{text}"
271
+ when :progress
272
+ ps[:done] = msg[:done].nil? ? ps.fetch(:done, 0) + 1 : msg[:done]
273
+ total = msg[:total] || ps[:total]
274
+ next unless show_progress?(msg[:done], total)
275
+ line = if msg[:info]
276
+ " [#{msg[:done]}/#{total}] #{msg[:info]}"
277
+ else
278
+ " [#{msg[:done]}/#{total}] (skipped)"
279
+ end
280
+ $stdout.puts " #{s.rjust(10)} |#{line}"
281
+ when :error
282
+ ps[:errors] = (ps[:errors] || 0) + 1
283
+ $stdout.puts " #{s.rjust(10)} | ERROR: #{msg[:message]}"
284
+ when :timeout
285
+ $stdout.puts " #{s.rjust(10)} | TIME BUDGET — #{msg[:remaining]} remaining"
286
+ when :total_timeout
287
+ $stdout.puts " *** TOTAL TIMEOUT #{msg[:seconds]}s — forcing shutdown"
288
+ end
289
+ $stdout.flush
290
+ end
291
+
292
+ @sources.each do |s|
293
+ info = per_source[s] || {}
294
+ err = info[:errors] || 0
295
+ if info[:auth_failed]
296
+ $stdout.puts " done: #{s}: 0 synced (auth failed)"
297
+ elsif info[:up_to_date]
298
+ $stdout.puts " done: #{s}: up to date"
299
+ elsif (cnt = info[:done]) && cnt > 0
300
+ label = err > 0 ? "#{cnt} synced, #{err} errors" : "#{cnt} synced"
301
+ $stdout.puts " done: #{s}: #{label}"
302
+ elsif info.key?(:done)
303
+ $stdout.puts " done: #{s}: 0 synced"
304
+ else
305
+ $stdout.puts " done: #{s}: 0 synced"
306
+ end
307
+ end
308
+ $stdout.flush
309
+ rescue => e
310
+ $stdout.puts " Runner output error: #{e.message}"
311
+ $stdout.flush
312
+ end
313
+
314
+ def show_progress?(done, total)
315
+ return true if done.nil? || done <= 3
316
+ return true if total && (total - done) <= 5
317
+ step = total && total > 0 ? [1, (total / 15.0).ceil].max : 1
318
+ step <= 1 || done % step == 0
319
+ end
320
+ end
321
+ end
322
+ end
@@ -1,7 +1,194 @@
1
+ require_relative "base"
2
+ require "faraday"
3
+
1
4
  module GitFit
2
5
  module Sync
6
+ class SyncError < RuntimeError; end unless const_defined?(:SyncError)
7
+
3
8
  class Strava < Base
9
+ BASE_URL = "https://www.strava.com/api/v3"
10
+
11
+ register_adapter
4
12
  register_config :client_id, :client_secret, :refresh_token
13
+
14
+ def authenticate
15
+ return false unless @config["client_id"].to_s != "" &&
16
+ @config["client_secret"].to_s != "" &&
17
+ @config["refresh_token"].to_s != ""
18
+ @access_token = refresh_access_token
19
+ @access_token ? true : false
20
+ end
21
+
22
+ def pending_ids
23
+ existing = existing_run_ids
24
+ ids = []
25
+ page = 1
26
+ loop do
27
+ activities = fetch_activities(@access_token, page)
28
+ break if activities.empty?
29
+ activities.each do |act|
30
+ act_id = act["id"].to_s
31
+ next if existing.include?(act_id) && raw_file_exists?(act_id)
32
+ type = map_sport_type(act["type"], act["sport_type"])
33
+ next if skip_type?(type)
34
+ ids << { id: act_id, type: type, summary: act }
35
+ end
36
+ page += 1
37
+ end
38
+ ids
39
+ end
40
+
41
+ def process_one(pending)
42
+ result = sync_activity(pending[:summary], pending[:id], @access_token)
43
+ result
44
+ end
45
+
46
+ private
47
+
48
+ def refresh_access_token
49
+ resp = Faraday.post("https://www.strava.com/oauth/token") do |req|
50
+ req.body = {
51
+ client_id: @config["client_id"],
52
+ client_secret: @config["client_secret"],
53
+ refresh_token: @config["refresh_token"],
54
+ grant_type: "refresh_token"
55
+ }
56
+ end
57
+ raise SyncError, "Strava token refresh failed: #{resp.status}" unless resp.success?
58
+ JSON.parse(resp.body)["access_token"]
59
+ end
60
+
61
+ def sync_activity(act, act_id, token)
62
+ type = map_sport_type(act["type"], act["sport_type"])
63
+ return false if skip_type?(type)
64
+
65
+ detail = fetch_detail(act_id, token)
66
+ return false unless detail
67
+
68
+ external_id = detail["external_id"]
69
+
70
+ streams = fetch_streams(act_id, token)
71
+
72
+ write_source_archive({ source: "strava", detail: detail, streams: streams }, act_id)
73
+
74
+ l2 = streams_to_l2(detail, streams)
75
+ write_standardized_json(l2, act_id) if l2
76
+
77
+ polyline = l2 ? Geo::Polyline.encode(l2.map { |pt| [pt[:positionLat], pt[:positionLong]] }) : act.dig("map", "summary_polyline")
78
+
79
+ attrs = build_attrs(act, type, polyline, external_id: external_id)
80
+ upsert_activity(attrs)
81
+ attrs
82
+ end
83
+
84
+ def fetch_activities(token, page)
85
+ resp = Faraday.get("#{BASE_URL}/athlete/activities") do |req|
86
+ req.headers["Authorization"] = "Bearer #{token}"
87
+ req.params = { page: page, per_page: 100 }
88
+ end
89
+ return [] unless resp.success?
90
+ JSON.parse(resp.body)
91
+ end
92
+
93
+ def fetch_detail(act_id, token)
94
+ resp = Faraday.get("#{BASE_URL}/activities/#{act_id}") do |req|
95
+ req.headers["Authorization"] = "Bearer #{token}"
96
+ end
97
+ return nil unless resp.success?
98
+ JSON.parse(resp.body)
99
+ end
100
+
101
+ def fetch_streams(act_id, token)
102
+ resp = Faraday.get("#{BASE_URL}/activities/#{act_id}/streams") do |req|
103
+ req.headers["Authorization"] = "Bearer #{token}"
104
+ req.params = { keys: "lat_lng,altitude,time,heartrate,cadence,watts,temp,velocity_smooth,distance", key_by_type: true }
105
+ end
106
+ return {} unless resp.success?
107
+ JSON.parse(resp.body)
108
+ end
109
+
110
+ def write_standardized_json(l2, act_id)
111
+ dir = std_dir
112
+ FileUtils.mkdir_p(dir)
113
+ tmp = File.join(dir, ".#{act_id}.json.tmp")
114
+ final = File.join(dir, "#{act_id}.json")
115
+ File.write(tmp, JSON.generate(l2))
116
+ File.rename(tmp, final)
117
+ end
118
+
119
+ def streams_to_l2(detail, streams)
120
+ polyline = detail.dig("map", "polyline")
121
+ return nil unless polyline
122
+
123
+ coords = Geo::Polyline.decode(polyline)
124
+ return nil if coords.empty?
125
+
126
+ alt_data = streams["altitude"]&.dig("data")
127
+ time_data = streams["time"]&.dig("data")
128
+ hr_data = streams["heartrate"]&.dig("data")
129
+ cad_data = streams["cadence"]&.dig("data")
130
+ watts_data = streams["watts"]&.dig("data")
131
+ temp_data = streams["temp"]&.dig("data")
132
+ speed_data = streams["velocity_smooth"]&.dig("data")
133
+ dist_data = streams["distance"]&.dig("data")
134
+
135
+ coords.each_with_index.map do |latlng, i|
136
+ pt = { positionLat: latlng[0], positionLong: latlng[1] }
137
+ pt[:altitude] = alt_data[i] if alt_data && i < alt_data.size
138
+ pt[:timestamp] = time_data[i] if time_data && i < time_data.size
139
+ pt[:heartRate] = hr_data[i] if hr_data && i < hr_data.size
140
+ pt[:cadence] = cad_data[i] if cad_data && i < cad_data.size
141
+ pt[:power] = watts_data[i] if watts_data && i < watts_data.size
142
+ pt[:temperature] = temp_data[i] if temp_data && i < temp_data.size
143
+ pt[:speed] = speed_data[i] if speed_data && i < speed_data.size
144
+ pt[:distance] = dist_data[i] if dist_data && i < dist_data.size
145
+ pt
146
+ end
147
+ end
148
+
149
+ def build_attrs(act, category, polyline = nil, external_id: nil)
150
+ start_t = Time.parse(act["start_date"]) rescue nil
151
+ local_t = Time.parse(act["start_date_local"]) rescue nil
152
+
153
+ {
154
+ run_id: "strava_#{act["id"]}",
155
+ name: act["name"],
156
+ distance: act["distance"],
157
+ moving_time: act["moving_time"],
158
+ elapsed_time: act["elapsed_time"],
159
+ sport_category: category,
160
+ sport_type: (act["sport_type"] || act["type"])&.downcase,
161
+ start_date: start_t&.iso8601,
162
+ start_date_local: local_t&.iso8601,
163
+ location_country: act["location_country"],
164
+ summary_polyline: polyline,
165
+ average_heartrate: act["average_heartrate"],
166
+ max_heartrate: act["max_heartrate"],
167
+ average_cadence: act["average_cadence"],
168
+ max_cadence: nil,
169
+ average_power: act["average_watts"],
170
+ max_power: act["max_watts"],
171
+ calories: act["calories"],
172
+ average_temperature: act["average_temp"],
173
+ average_speed: act["average_speed"],
174
+ elevation_gain: act["total_elevation_gain"],
175
+ source: "strava",
176
+ external_id: external_id
177
+ }
178
+ end
179
+
180
+ def map_sport_type(type, sport_type)
181
+ SportMapper.canonicalize(sport_type || type)
182
+ end
183
+
184
+ def raw_path(platform_id)
185
+ File.join(raw_dir, "#{platform_id}.json")
186
+ end
187
+
188
+ def existing_run_ids
189
+ @db[:activities].where(source: "strava").select_map(:run_id)
190
+ .map { |id| id.sub("strava_", "") }
191
+ end
5
192
  end
6
193
  end
7
194
  end