Ended sessions past the ~24h Garmin appears to keep a live-track page around for get verified once at app startup and, once confirmed dead, flagged so the panel greys them out instead of only failing after a click. Already-flagged sessions are skipped on later startups so this doesn't keep re-hitting Garmin for the same dead session.
130 lines
4.2 KiB
Ruby
130 lines
4.2 KiB
Ruby
require "json"
|
|
require "fileutils"
|
|
require "time"
|
|
|
|
module Maptool
|
|
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
|
|
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
|
|
# The token is what actually lets us fetch the session's track (see
|
|
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
|
|
# and the id alone isn't enough.
|
|
#
|
|
# A session's own summary (see LiveTrackClient.summarize), once fetched,
|
|
# is kept indefinitely — it's small, and worth keeping as a record of the
|
|
# activity even after Garmin stops serving the live session. A session
|
|
# nobody ever opened (so has no summary) is just clutter — likely never
|
|
# even a real one, going by how this webhook has been tested — and is
|
|
# dropped after UNSUMMARIZED_TTL.
|
|
class LiveTrackStore
|
|
UNSUMMARIZED_TTL = 48 * 60 * 60 # seconds
|
|
|
|
def initialize(path)
|
|
@path = path
|
|
@mutex = Mutex.new
|
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
File.write(@path, "[]") unless File.exist?(@path)
|
|
end
|
|
|
|
# Records `session_id`/`token`, moving it to the front if already
|
|
# present (its summary, if any, carries over).
|
|
def add(session_id, token)
|
|
@mutex.synchronize do
|
|
sessions = prune(read)
|
|
existing = sessions.find { |s| s["id"] == session_id }
|
|
sessions.reject! { |s| s["id"] == session_id }
|
|
sessions.unshift(
|
|
"id" => session_id,
|
|
"token" => token,
|
|
"received_at" => Time.now.utc.iso8601,
|
|
"summary" => existing && existing["summary"]
|
|
)
|
|
write(sessions)
|
|
end
|
|
end
|
|
|
|
# Attaches a summary (see LiveTrackClient.summarize) to a stored session,
|
|
# exempting it from UNSUMMARIZED_TTL from here on.
|
|
def set_summary(session_id, summary)
|
|
@mutex.synchronize do
|
|
sessions = read
|
|
session = sessions.find { |s| s["id"] == session_id }
|
|
return unless session
|
|
|
|
session["summary"] = summary
|
|
write(sessions)
|
|
end
|
|
end
|
|
|
|
# Ended sessions (summary["active"] == false) that ended more than
|
|
# `stale_after` seconds ago and aren't already flagged unavailable —
|
|
# i.e. ones Garmin has very likely stopped serving the live-track page
|
|
# for, but that haven't been confirmed yet (see app.rb's startup
|
|
# check). Excludes already-flagged ones so that check doesn't re-hit
|
|
# Garmin, on every restart, for a session it already told us is gone.
|
|
def stale_ended(stale_after)
|
|
cutoff = Time.now.utc - stale_after
|
|
list.select { |s| stale_ended?(s, cutoff) }
|
|
end
|
|
|
|
# Flags a session's live-track page as no longer served by Garmin, once
|
|
# confirmed (see app.rb's startup check) — the summary itself (small,
|
|
# and a record of the activity, see the class comment) is left alone.
|
|
def mark_unavailable(session_id)
|
|
@mutex.synchronize do
|
|
sessions = read
|
|
session = sessions.find { |s| s["id"] == session_id }
|
|
return unless session
|
|
|
|
session["map_available"] = false
|
|
write(sessions)
|
|
end
|
|
end
|
|
|
|
# Most recently received first.
|
|
def list
|
|
prune(read)
|
|
end
|
|
|
|
def find(session_id)
|
|
list.find { |s| s["id"] == session_id }
|
|
end
|
|
|
|
private
|
|
|
|
def prune(sessions)
|
|
cutoff = Time.now.utc - UNSUMMARIZED_TTL
|
|
sessions.select { |s| s["summary"] || received_at(s) > cutoff }
|
|
end
|
|
|
|
def received_at(session)
|
|
Time.parse(session["received_at"])
|
|
rescue ArgumentError, TypeError
|
|
Time.now.utc # malformed/missing timestamp: don't let it block pruning
|
|
end
|
|
|
|
def stale_ended?(session, cutoff)
|
|
summary = session["summary"]
|
|
return false unless summary && summary["active"] == false && session["map_available"] != false
|
|
|
|
ended = ended_at(summary)
|
|
ended && ended < cutoff
|
|
end
|
|
|
|
def ended_at(summary)
|
|
Time.parse(summary["end"])
|
|
rescue ArgumentError, TypeError
|
|
nil # malformed/missing "end": can't tell how long it's been, so don't guess
|
|
end
|
|
|
|
def write(sessions)
|
|
File.write(@path, JSON.pretty_generate(sessions))
|
|
end
|
|
|
|
def read
|
|
JSON.parse(File.read(@path))
|
|
rescue Errno::ENOENT, JSON::ParserError
|
|
[]
|
|
end
|
|
end
|
|
end
|