Files
Yevhen Odynets 346369ca11 srrdb: harvest the srrDB search API into a separate SQLite database
New scope reading the API instead of scraping HTML: one search path per run,
passed raw through --single-uri, and the answer stored as-is into its own
SQLite file created from the embedded schema.

Command line: --single-uri carries the path after /v1/search/
(category:flac/date:today/page:1) and must name a category; --sleep sets the
pause between pages, with the config value as fallback. A day named on the
command line is always fetched again and its dump rewritten, so cron can
refresh today hourly and yesterday daily.

Date placeholders: date:auto walks backwards to the newest day in
[srrdb.floor..yesterday] that has no dump yet, closing holes instead of
declaring the archive covered. date:today, date:yesterday and date:tomorrow
stand for the neighbouring days. Today is never taken by the walk, because a
day still being filled would be frozen by its own dump after the first run.

Dumps are the cursor as well as the record, so a missing dump directory is an
error instead of "nothing harvested yet": a renamed, unmounted or mistyped
directory must not send the run silently back to the beginning. An empty day is
a normal result and is remembered as harvested; API warnings never are, and
they reach the exit code.

Writes go through one transaction, so a day lands completely or not at all.
group and category come from the release line and the search path; status, nfo,
content and synced_at stay NULL. srrdb.log.jsonl records one line per finished
run, srrdb.error_log.jsonl keeps failures and API warnings apart by a kind
field.

Also: the console command contract returns (string, error), so a failing scope
reaches the process exit code and cron can tell a failed run from a successful
one. config/scope/srrdb.yaml is untracked because its paths differ per host.
modernc.org/sqlite replaces the archived libsql client, which only ever talked
to a server. pkg/repository/srrdb.go is split by responsibility and covered by
table-driven tests.
2026-09-30 09:27:33 +03:00

190 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/spf13/viper"
)
// srrdbAutoSegment is the placeholder replaced by the date the run has to
// harvest, so cron can repeat the same command every day.
const srrdbAutoSegment = "date:auto"
// srrdbYesterdaySegment, srrdbTodaySegment and srrdbTomorrowSegment stand for
// the neighbouring days, so cron can name a day without hard-coding a date.
const (
srrdbYesterdaySegment = "date:yesterday"
srrdbTodaySegment = "date:today"
srrdbTomorrowSegment = "date:tomorrow"
)
// srrdbDayLayout is the layout of a date segment in a search path.
const srrdbDayLayout = "2006-01-02"
// errSrrdbFloor reports that the automatic walk reached srrdb.floor, which is
// the archive being fully covered rather than a failure.
var errSrrdbFloor = errors.New("srrdb.floor reached")
// srrdbResolveAutoDate replaces date:auto with the newest day in
// [srrdb.floor..yesterday] that has not been harvested yet.
//
// Scanning from the top-down means a new day is picked up the moment it appears,
// and a hole left in the middle is closed once the walk reaches it. The previous
// rule (the day before the earliest dump) treated the harvested days as one
// uninterrupted run, so a single dump below the floor made it declare the whole
// archive covered while everything above stayed missing.
//
// The current moment is passed in rather than read here, so the walk can be tested
// without depending on the wall clock.
func srrdbResolveAutoDate(rawURI string, now time.Time) (string, error) {
if !strings.Contains(rawURI, srrdbAutoSegment) {
return rawURI, nil
}
floor, err := srrdbFloorDate()
if err != nil {
return "", err
}
harvested, err := srrdbDumpedDays(rawURI)
if err != nil {
return "", err
}
done := make(map[string]bool, len(harvested))
for _, day := range harvested {
done[day.Format(srrdbDayLayout)] = true
}
// The current day is never taken: it is still being filled, so it would have
// no dump and would be chosen by every single run, freezing the walk. cron
// refreshes today explicitly instead.
for day := now.AddDate(0, 0, -1); !day.Before(floor); day = day.AddDate(0, 0, -1) {
if !done[day.Format(srrdbDayLayout)] {
return srrdbReplaceDate(rawURI, srrdbAutoSegment, day), nil
}
}
return "", errSrrdbFloor
}
// srrdbDumpedDays lists the days already harvested for the same query, read back
// from the dump file names inside srrdb.dump_dir.
//
// A missing directory is an error rather than "nothing harvested yet": the dumps
// are the cursor of the walk, so a renamed, unmounted or mistyped directory would
// otherwise send the run silently back to the beginning. The directory is created
// once by hand, exactly like the database file.
func srrdbDumpedDays(uri string) ([]time.Time, error) {
pattern, err := srrdbDumpPattern(uri)
if err != nil {
return nil, err
}
dir := viper.GetString("srrdb.dump_dir")
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read srrdb dump dir %q: %w", dir, err)
}
days := make([]time.Time, 0, len(entries))
for _, entry := range entries {
match := pattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
day, err := time.Parse(srrdbDayLayout, match[1])
if err != nil {
continue
}
days = append(days, day)
}
return days, nil
}
// srrdbDumpPattern builds the regular expression matching the dump names of the
// same query, capturing the day segment so it can be read back. The literal part
// is quoted first, therefore the capture group is the only pattern syntax left in
// the result.
func srrdbDumpPattern(uri string) (*regexp.Regexp, error) {
const normalizedAuto = "date:auto"
escaped := regexp.QuoteMeta(srrdbNormalizedPath(uri))
head, tail, ok := strings.Cut(escaped, regexp.QuoteMeta(normalizedAuto))
if !ok {
return nil, fmt.Errorf("srrdb dump pattern needs a %s segment, got %q", srrdbAutoSegment, uri)
}
return regexp.Compile("^" + head + `date:(\d{4}-\d{2}-\d{2})` + tail + "$")
}
// srrdbFloorDate is the earliest day the automatic walk may ask for, kept in
// configuration because it is a property of the archive, not of the code.
//
// The value is read through viper.Get rather than viper.GetString: YAML decodes
// an unquoted date as a timestamp, and asking for its string form yields
// "2013-03-16 00:00:00 +0000 UTC", which the day layout cannot parse. Both the
// quoted and the unquoted spelling are accepted here.
func srrdbFloorDate() (time.Time, error) {
switch raw := viper.Get("srrdb.floor").(type) {
case nil:
return time.Time{}, errors.New("srrdb.floor is not configured")
case time.Time:
return raw, nil
case string:
floor, err := time.Parse(srrdbDayLayout, raw)
if err != nil {
return time.Time{}, fmt.Errorf("parse srrdb.floor %q: %w", raw, err)
}
return floor, nil
default:
return time.Time{}, fmt.Errorf("srrdb.floor has unexpected type %T", raw)
}
}
// srrdbReplaceDate swaps a date placeholder for a concrete day.
func srrdbReplaceDate(uri, segment string, day time.Time) string {
return strings.Replace(uri, segment, "date:"+day.Format(srrdbDayLayout), 1)
}
// srrdbRelativeDays holds the date placeholders that stand for a day counted
// from now. date:auto is deliberately absent: it means "the next day the walk
// has to cover", which only srrdbResolveAutoDate can decide.
var srrdbRelativeDays = []struct {
segment string
offset int
}{
{segment: srrdbYesterdaySegment, offset: -1},
{segment: srrdbTodaySegment, offset: 0},
{segment: srrdbTomorrowSegment, offset: 1},
}
// srrdbResolveRelativeDays expands every relative date placeholder present in the
// path. The current moment is passed in for the same reason as in
// srrdbResolveAutoDate: a test has to be able to pin it.
func srrdbResolveRelativeDays(rawURI string, now time.Time) string {
uri := rawURI
for _, relative := range srrdbRelativeDays {
if !strings.Contains(uri, relative.segment) {
continue
}
uri = srrdbReplaceDate(uri, relative.segment, now.AddDate(0, 0, relative.offset))
}
return uri
}