Files
Timmy Welch b85659a845 Remove IDList
Use a slice instead
2026-11-05 16:29:01 -08:00

656 lines
20 KiB
Go

// Package metron
package metron
import (
"bytes"
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.narnian.us/lordwelch/comic-hasher/ch"
dl "gitea.narnian.us/lordwelch/comic-hasher/download"
"gitea.narnian.us/lordwelch/comic-hasher/log"
"github.com/go-json-experiment/json"
bolt "go.etcd.io/bbolt"
)
var logger = log.NewLogger("metron", "downloads")
// Buckets
const (
BucketIssue = "issues"
BucketPageCache = "page cache"
BucketMeta = "metadata"
)
// Keys
const (
KeyCurrentStartDate Key = "current start date" // We may get interupted before we get a full run done so we need to keep track of the current run and the next run
KeyNextStartDate Key = "next start date" // The date that this run was originally started, that way we aren't crawling the entire site repeatedly
KeyMaxID Key = "max issue id" // After we have a first run: c.StartDate.IsZero() != true this is an optimization to skip processing
// KEY_NEXT_PAGE Key = "next page" // Url to the next page according to the last c.StartDate
KeyRetryDate Key = "retry date" // Date given by api to try again on
)
var BaseURL = MustParseURL("https://metron.cloud/api/")
const (
WorkDirectory = "metron"
imageDirectory = "_image"
TimestampLayout = "2006-01-02T15:04:05.999999-07:00"
DBVersion = "1.0.1"
PageCacheVersion = "1.0.0"
)
var ErrRetry = errors.New("Retry")
// const defaultLimit = 5000 / rate.Limit(time.Hour*24)
var JSONOpts = []json.Options{
json.ExperimentalSupportFormatTag(true),
json.WithMarshalers(json.JoinMarshalers(
json.MarshalToFunc(URLEncoder),
)),
json.WithUnmarshalers(json.JoinUnmarshalers(
json.UnmarshalFromFunc(URLDecoder),
)),
}
type Auth string
func (a Auth) Apply(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+string(a))
}
func URLDecoder(dec *jsontext.Decoder, u *url.URL) error {
if k := dec.PeekKind(); k != jsontext.KindString {
if k == jsontext.KindNull {
_, err := dec.ReadValue()
return err
}
return &json.SemanticError{JSONKind: k}
}
strURL := ""
err := json.UnmarshalDecode(dec, &strURL)
if err != nil {
return err
}
tmpURL, err := url.Parse(strURL)
if err != nil {
return err
}
*u = *tmpURL
return nil
}
func URLEncoder(enc *jsontext.Encoder, u url.URL) error {
return enc.WriteToken(jsontext.String(u.String()))
}
type Series struct {
ID int64 `json:"id"` // 3856
Name string `json:"Name"` // "The 06 Protocol"
Volume int `json:"Volume"` // 1
YearBegan int `json:"year_began"` // 2022
}
type Issue struct {
Series Series `json:"series"`
ID int64 `json:"id"` // 52529
Number string `json:"number"` // "1"
Issue string `json:"issue"` // "The 06 Protocol (2022) #1"
CoverDate time.Time `json:"cover_date,format:'2006-01-02'"` // "2022-09-01"
StoreDate time.Time `json:"store_date,format:'2006-01-02'"` // "2022-09-14"
Image url.URL `json:"image"` // "https://static.metron.cloud/media/issue/2022/09/19/6-protocol-1.jpg"
Coverhash string `json:"cover_hash"` // "e9529e3586ca6b34"
Modified time.Time `json:"modified,format:'2006-01-02T15:04:05.999999-07:00'"` // "2024-12-23T16:17:29.333263-05:00"
}
type ResultList struct {
Count int `json:"count"` // 123
Next url.URL `json:"next"` // "http://api.example.org/accounts/?page=4",
Previous url.URL `json:"previous"` // "http://api.example.org/accounts/?page=2",
Results []Issue `json:"results"`
}
type Downloader struct {
Context context.Context
basePath string
version string
SendExistingImages bool
KeepDownloadedImages bool
onlyHashNewIDs bool
StartDownloadQueue chan dl.Download
FinishDownloadQueue chan<- dl.Download
Auth Auth
DB Bolt
PageCache Bolt
StartDate time.Time
totalResults int
// notFound chan download
chdb ch.CHDB
bufPool *sync.Pool
getID func(id ch.ID) []ch.ID
}
// Key is a key to an item in a bolt bucket
type Key string
type Bolt struct {
ch.Bolt
}
func (m Bolt) Issue(id int64) Issue {
var issue Issue
err := m.DB.View(func(tx *bolt.Tx) error {
return json.Unmarshal(tx.Bucket([]byte(BucketIssue)).Get(ch.IntToBolt(id)), &issue, JSONOpts...)
})
if err != nil {
return Issue{
ID: -1,
}
}
return issue
}
func (m Bolt) PutIssue(issue Issue) error {
// The metron api is a list of `Issue` and must be parsed out,
// we will never really have access to the original bytes
// so we just need to validate that the json marshalling this doesn't break the format
return m.DB.Update(func(tx *bolt.Tx) error {
if issue == (Issue{}) {
return tx.Bucket([]byte(BucketIssue)).Delete(ch.IntToBolt(issue.ID))
}
content, err := json.Marshal(issue, JSONOpts...)
if err != nil {
return fmt.Errorf("failed to convert issue to bytes: %w", err)
}
return tx.Bucket([]byte(BucketIssue)).Put(ch.IntToBolt(issue.ID), content)
})
}
func (m Bolt) Date(key Key) time.Time {
var startDate time.Time
err := m.DB.View(func(tx *bolt.Tx) error {
return startDate.UnmarshalBinary(tx.Bucket([]byte(BucketMeta)).Get([]byte(key)))
})
if err != nil {
return time.Time{}
}
return startDate
}
func (m Bolt) PutDate(key Key, startDate time.Time) error {
return m.DB.Update(func(tx *bolt.Tx) error {
if startDate.IsZero() {
tx.Bucket([]byte(BucketMeta)).Delete([]byte(key))
}
content, err := startDate.MarshalBinary()
if err != nil {
return fmt.Errorf("failed to save start date to database: %w", err)
}
return tx.Bucket([]byte(BucketMeta)).Put([]byte(key), content)
})
}
func (m Bolt) URL(key Key) url.URL {
var value url.URL
err := m.DB.View(func(tx *bolt.Tx) error {
return value.UnmarshalBinary(tx.Bucket([]byte(BucketMeta)).Get([]byte(key)))
})
if err != nil {
return url.URL{}
}
return value
}
func (m Bolt) PutURL(key Key, value url.URL) error {
return m.DB.Update(func(tx *bolt.Tx) error {
if value == (url.URL{}) {
return tx.Bucket([]byte(BucketMeta)).Delete([]byte(key))
}
return tx.Bucket([]byte(BucketMeta)).Put([]byte(key), []byte(value.String()))
})
}
func (m Bolt) Get(bucket, key string) []byte {
buf := make([]byte, 0, 16)
err := m.DB.View(func(tx *bolt.Tx) error {
buf = append(buf, tx.Bucket([]byte(bucket)).Get([]byte(key))...)
return nil
})
if err != nil {
return nil
}
return buf
}
func (m Bolt) Put(bucket, key string, value []byte) error {
return m.DB.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bucket)).Put([]byte(key), value)
})
}
func NewDownloader(
version string,
bufPool *sync.Pool,
onlyHashNewIDs bool,
getID func(id ch.ID) []ch.ID,
chdb ch.CHDB,
basePath, APIKey string,
keepDownloadedImages, sendExistingImages bool,
finishDownloadQueue chan<- dl.Download,
) *Downloader {
if os.Getenv("METRON_KEEP_IMAGES") == "TRUE" {
keepDownloadedImages = true
}
return &Downloader{
basePath: basePath,
version: version,
Auth: Auth(APIKey),
bufPool: bufPool, // The buffers get sent back via finishedDownloadQueue
FinishDownloadQueue: finishDownloadQueue,
SendExistingImages: sendExistingImages,
KeepDownloadedImages: keepDownloadedImages,
chdb: chdb,
getID: getID,
onlyHashNewIDs: onlyHashNewIDs,
}
}
func (d *Downloader) cleanDirs() {
_ = filepath.WalkDir(filepath.Join(d.basePath, imageDirectory), func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
path, _ = filepath.Abs(path)
err := ch.RmdirP(path)
// The error is only for the first path value. EG ch.RmdirP("/test/t") will only return the error for os.Remove("/test/t") not os.Remove("test")
if err == nil {
return filepath.SkipDir
}
}
return nil
})
}
func MustParseURL(rawURI string) *url.URL {
uri, err := url.Parse(rawURI)
if err != nil {
panic(fmt.Errorf("failed to parse url: %w", err))
}
return uri
}
func (d *Downloader) getPage(pageURL url.URL) ([]byte, time.Time, error) {
content := d.PageCache.Get(BucketPageCache, pageURL.String())
if len(content) > 0 {
logger.Println(log.Standard, "Checking metron page", pageURL.String())
return content, time.Time{}, nil
}
logger.Println(log.Standard, "Downloading metron page", pageURL.String())
req, _ := http.NewRequest(http.MethodGet, pageURL.String(), nil)
d.Auth.Apply(req)
resp, respCancel, err := dl.Get(d.Context, req)
if err != nil {
return nil, time.Time{}, fmt.Errorf("failed to retrieve %q: %w", pageURL.String(), err)
}
sleepTo := GetNextRequestTime(resp)
defer respCancel()
content, err = io.ReadAll(resp.Body)
if err != nil {
return nil, sleepTo, fmt.Errorf("failed to download page: %s: %w: %w", pageURL.String(), err, ErrRetry)
}
if resp.StatusCode == http.StatusOK {
err = d.PageCache.Put(BucketPageCache, pageURL.String(), content)
if err != nil {
return content, sleepTo, fmt.Errorf("failed to cache metron search page: %v: %w", pageURL, err)
}
return content, sleepTo, nil
}
if resp.StatusCode == http.StatusTooManyRequests {
return content, sleepTo, ErrRetry
}
return content, sleepTo, fmt.Errorf("generic http error: %d: %s", resp.StatusCode, content)
}
// https://metron.cloud/api/issue/?modified_gt
func (d *Downloader) processIssues() error {
if d.DB.DB == nil {
panic("DB not set")
}
if d.PageCache.DB == nil {
panic("DB not set")
}
var resultList ResultList
// We always start at the first page. It should already be cached so should not use any extra api requests
resultList.Next = *BaseURL.ResolveReference(&url.URL{Path: "issue/"})
if !d.StartDate.IsZero() {
q := resultList.Next.Query()
q.Add("modified_gt", d.StartDate.Format(TimestampLayout))
resultList.Next.RawQuery = q.Encode()
}
retries := 0
// TODO: we have a count attribute. Calculate what page we should end on so that we don't have an infinite loop here
nextAPIRequestTime := time.Time{}
for resultList.Next != (url.URL{}) {
err := d.Context.Err()
if err != nil {
return err
}
currentURL := resultList.Next
resultList.Count = 0
resultList.Next = url.URL{}
resultList.Previous = url.URL{}
resultList.Results = resultList.Results[0:0]
logger.Println(log.MoreVerbose, "Checking url", resultList.Next.String())
// So defer can be used
err = func() error {
var (
content []byte
err error
)
if !nextAPIRequestTime.IsZero() {
timeLeft := time.Until(nextAPIRequestTime)
if timeLeft > time.Millisecond*100 {
select {
case <-time.After(timeLeft):
case <-d.Context.Done():
return d.Context.Err()
}
}
}
content, nextAPIRequestTime, err = d.getPage(currentURL)
if errors.Is(err, ErrRetry) {
retries += 1
if retries >= 3 {
return errors.New("to many retries")
}
resultList.Next = currentURL
return nil
}
retries = 0
if err != nil {
return fmt.Errorf("failed to request issues from metron API: %w", err)
}
err = json.Unmarshal(content, &resultList, JSONOpts...)
if err != nil {
return fmt.Errorf("failed to decode json response from %q: %w\n%s", currentURL.String(), err, content)
}
var issue Issue
// This is mainly to keep the data available for later changes to the server it's not used otherwise
for _, issue = range resultList.Results {
err = d.DB.PutIssue(issue)
if err != nil {
return fmt.Errorf("failed to save issue %d to disk: %w", issue.ID, err)
}
}
var imageNextRequestTime time.Time
for _, issue = range resultList.Results {
err = d.Context.Err()
if err != nil {
return err
}
issueID := ch.ID{Domain: ch.NewSource(ch.Metron), ID: strconv.Itoa(int(issue.ID))}
logger.Println(log.MoreVerbose, "Checking id:", issueID)
imageRequest, _ := http.NewRequest(http.MethodGet, issue.Image.String(), nil)
ext := strings.ToLower(path.Ext(issue.Image.Path))
if ext == "" || (len(ext) > 4 && !slices.Contains([]string{".avif", ".webp", ".tiff", ".heif"}, ext)) {
ext = ".jpg"
}
// Construct the relative path for the file
imagePath := filepath.Join(imageDirectory, strconv.Itoa(int(issue.Series.ID)), strconv.Itoa(int(issue.ID)), "cover"+ext)
buf := d.bufPool.Get().(*bytes.Buffer)
buf.Reset()
if issue.Image == (url.URL{}) {
logger.Println(log.Standard, "No url found for:", issueID)
continue
}
if d.chdb.CheckURL(issue.Image.String()) {
logger.Println(log.Verbose, "Skipping known bad url:", issue.Image)
continue
}
if d.chdb.PathDownloaded(filepath.Join(WorkDirectory, imagePath)) {
logger.Println(log.Standard, "Issue is already downloaded:", issueID)
if !d.SendExistingImages {
continue
}
// d.basePath is the absolute path to WorkDirectory
if _, err = os.Stat(filepath.Join(d.basePath, imagePath)); err != nil {
continue // assume it's already been hashed and deleted
}
err = dl.ReadAll(filepath.Join(d.basePath, imagePath), buf)
if err != nil {
logger.Log(log.Standard, "Failed to read existing image %q: %v", imagePath, err)
continue
}
d.FinishDownloadQueue <- dl.Download{
Request: imageRequest,
IssueID: issueID,
// This is relative to to comic-hasher path. eg filepath.Join(c.basePath, "../")
Dest: filepath.Join(WorkDirectory, imagePath),
Image: buf,
}
continue
}
existingIDs := d.getID(issueID)
logger.Println(log.Verbose, "Checking if ID:", issueID, "is already hashed", existingIDs)
if d.onlyHashNewIDs && len(existingIDs) > 0 {
logger.Println(log.Verbose, "Skipping already hashed ID:", issueID)
continue
}
if sleepTime := time.Until(imageNextRequestTime); sleepTime > time.Millisecond*100 {
select {
case <-time.After(sleepTime):
case <-d.Context.Done():
return d.Context.Err()
}
}
coverResp := dl.DownloadImage(
dl.Download{
Request: imageRequest,
IssueID: issueID,
// This is relative to to comic-hasher path. eg filepath.Join(c.basePath, "../")
Dest: filepath.Join(WorkDirectory, imagePath),
Image: buf,
},
d.Context,
d.chdb,
filepath.Dir(d.basePath),
d.KeepDownloadedImages,
d.FinishDownloadQueue,
0,
)
_ = coverResp
// Covers are cached by cloudflare. Default is 1 second. But if x-ratelimit headers are ever added they will be parsed
imageNextRequestTime = GetNextRequestTime(coverResp)
}
return nil
}()
if err != nil {
return err
}
}
return nil
}
func GetServerTime(resp *http.Response) time.Time {
now := time.Now()
serverTime := resp.Header.Get("Date")
if serverTime == "" {
return now
}
t, err := time.Parse(time.RFC1123, serverTime)
if err != nil {
return now
}
return t
}
// GetRatelimitTime checks these http headers
// x-ratelimit-burst-limit: 45
// x-ratelimit-burst-remaining: 41
// x-ratelimit-burst-reset: 1790207705 // 1 Minute
// x-ratelimit-sustained-limit: 5000
// x-ratelimit-sustained-remaining: 4983
// x-ratelimit-sustained-reset: 1790283530 // 24 Hours
// Retry-After: 60 // Seconds to next reset if limit is hit. Realistically this will be x-ratelimit-burst-reset or x-ratelimit-sustained-reset
func GetRatelimitTime(resp *http.Response, ratelimit string) (time.Time, error) {
remaining := resp.Header.Get("x-ratelimit-" + ratelimit + "-remaining")
resetTime := resp.Header.Get("x-ratelimit-" + ratelimit + "-reset")
if remaining == "" || resetTime == "" {
return time.Time{}, nil
}
limit, err := strconv.Atoi(remaining)
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse %s limit remaining: %w", ratelimit, err)
}
if limit < 1 {
sustainedReset, err := strconv.ParseInt(resetTime, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse %s limit reset: %w", ratelimit, err)
}
return time.Unix(sustainedReset, 0), nil
}
return time.Time{}, nil
}
func GetNextRequestTime(resp *http.Response) time.Time {
if resp == nil {
return time.Time{}
}
serverTime := GetServerTime(resp)
if resp.Header.Get("Retry-After") != "" {
retrySeconds, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64)
if err != nil {
logger.Log(log.Required, "Unable to parse %#v: %v", resp.Header.Get("Retry-After"), err)
return serverTime.Add(10 * time.Second)
}
return serverTime.Add(time.Duration(retrySeconds) * time.Second)
}
serverTime = time.Now()
t, err := GetRatelimitTime(resp, "sustained")
if err != nil || t.IsZero() {
t, err = GetRatelimitTime(resp, "burst")
}
if err != nil {
logger.Log(log.Required, "Unable to parse ratelimit: %v", err)
return serverTime.Add(10 * time.Second)
}
if t.IsZero() {
return serverTime.Add(time.Second)
}
return t
}
func OpenBolt(path string, version ch.Version, buckets []string) (Bolt, error) {
logger.Println(log.MoreVerbose, "Opening bolt:", path)
b, err := ch.OpenBolt(path, version, buckets)
if err != nil {
return Bolt{b}, err
}
return Bolt{b}, nil
}
func DownloadCovers(ctx context.Context, d *Downloader) {
var err error
d.Context = log.NewContext(ctx, logger)
_ = os.MkdirAll(d.basePath, 0o777)
d.DB, err = OpenBolt(filepath.Join(d.basePath, "metron.bolt"), DBVersion, []string{BucketIssue, BucketMeta})
if err != nil {
logger.Log(log.Fatal, "failed to open metron database: %v", err)
return
}
defer d.DB.Close()
err = d.DB.Compact()
if err != nil {
logger.Println(log.Verbose, "Failed to compact the metron database, this is probably fine:", err)
}
ImagePath := filepath.Join(d.basePath, imageDirectory)
d.StartDate = d.DB.Date(KeyCurrentStartDate)
nextStartDate := d.DB.Date(KeyNextStartDate)
if nextStartDate.IsZero() {
nextStartDate = time.Now()
d.DB.PutDate(KeyNextStartDate, nextStartDate)
}
d.PageCache, err = OpenBolt(filepath.Join(d.basePath, "page_cache.bolt"), PageCacheVersion, []string{BucketPageCache, BucketMeta})
if err != nil {
logger.Log(log.Fatal, "failed to open metron page cache database: %v", err)
return
}
defer d.PageCache.Close()
if d.StartDate != d.PageCache.Date(KeyCurrentStartDate) {
// Page cache will cycle through a lot of data especially on the initial run
// instead of deleting entries and compacting delete the entire file if it doesn't match the current run
d.PageCache.Close()
os.Remove(filepath.Join(d.basePath, "page_cache.bolt"))
logger.Println(log.Verbose, "Re-creating page cache")
d.PageCache, err = OpenBolt(filepath.Join(d.basePath, "page_cache.bolt"), PageCacheVersion, []string{BucketPageCache, BucketMeta})
if err != nil {
logger.Log(log.Fatal, "failed to open metron page cache database: %v", err)
return
}
defer d.PageCache.Close()
err = d.PageCache.PutDate(KeyCurrentStartDate, d.StartDate)
if err != nil {
logger.Log(log.Fatal, "failed to save the page cache start date: %v", err)
return
}
}
err = d.PageCache.Compact()
if err != nil {
logger.Println(log.Verbose, "Failed to compact the metron page cache database, this is probably fine:", err)
}
_ = os.MkdirAll(ImagePath, 0o777)
f, _ := os.Create(filepath.Join(ImagePath, ".keep"))
_ = f.Close()
if !d.KeepDownloadedImages {
logger.Println(log.MoreVerbose, "Cleaning directories")
d.cleanDirs()
}
logger.Log(log.Standard, "Updating metron issues now")
err = d.processIssues()
if err != nil {
logger.Log(log.Fatal, "Failed to download new metron Issues: %s", err)
return
}
err = d.DB.PutDate(KeyCurrentStartDate, nextStartDate)
if err != nil {
logger.Println(log.Fatal, "Failed to save metron modified dates")
}
err = d.DB.PutDate(KeyNextStartDate, time.Time{})
if err != nil {
logger.Println(log.Fatal, "Failed to save metron modified dates")
}
logger.Println(log.Standard, "Completed downloading covers")
}