Compare commits

..

16 Commits

Author SHA1 Message Date
Yevhen Odynets 04b5bfa89d fixed 404 error on NFO downlaod 2026-10-01 19:39:10 +03:00
Yevhen Odynets 9812bc3552 change nfo update paradigma 2026-10-01 09:54:55 +03:00
Yevhen Odynets eab6c63eec changed nfo path 2026-10-01 09:52:13 +03:00
Yevhen Odynets fcbbf4f298 change nfo update paradigma 2026-10-01 09:50:26 +03:00
Yevhen Odynets a7f57a43a0 config for prescene fixed 2026-10-01 07:44:38 +03:00
Yevhen Odynets ebb86d094a feat(prescene): parse single scnlog release for --single-uri page::1m
Handle the page::1m marker before the generic single-uri branch
(previously unreachable):

- select the latest prescene source from external_sources with meta IS NULL
  (sql.ErrNoRows -> no-op)
- parse article.single: type_id from post-(\d+), size_mb, genre,
  unique artists, tracks count and total duration (h:M:s)
- download the release NFO (anchor "Download NFO" -> onclick href)
  and save it to <nfo_dir>/<first-letter-of-title>/<title>.nfo
- update external_sources.meta with compact JSON by id via db.Exec,
  writing {} when every field is empty

Add model.ScnlogMeta / model.ScnlogSource structures.
2026-10-01 07:27:26 +03:00
Yevhen Odynets a2bfa8ea71 srrdb: persist per-day harvest ledger alongside releases
Add srrdb_log table with composite primary key (day, category) and counters
found, stored, ignored plus run_at.

Replace the release-only persistence path with UpsertAllSrrdbWithDay so releases
and their corresponding srrdb_log row are written in one SQLite transaction.
Preserve existing error handling: rollback reports the release/day that failed,
and commit errors return without writing a dump.

Handle fully ignored days as closed. When every fetched release is skipped by
ignore rules, still write the ledger row with zero stored releases. This prevents
the automatic day walk from endlessly retrying a day whose releases are filtered
out.

Derive and validate the ledger day from the resolved request URI. Missing or
invalid date: segments now fail before database writes. Keep run_at independent
from created_at semantics and continue populating created_at from the run time.

Preserve column and upsert semantics. category continues to use NOT NULL DEFAULT
empty string; status keeps COALESCE behavior on conflict; nfo, content, and
synced_at remain unchanged.
2026-09-30 12:44:40 +03:00
Yevhen Odynets 8e986ad2f1 change slite driver settings 2026-09-30 09:56:01 +03:00
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
Yevhen Odynets 332d143f1f change dump output for prescene 2026-09-29 08:17:11 +03:00
Yevhen Odynets f53a624004 chnage dump output to pretty 2026-09-27 01:59:03 +03:00
Yevhen Odynets 39aa8e2e81 fixed a new prescene layout 2026-09-27 01:12:52 +03:00
Yevhen Odynets 347f525801 releaseparser: Artist/Title boundary resolution + normalized Type field
Three-level core resolution with priority overrides → dictionaries/model → suspects:

1. dicts/overrides.txt (highest priority)
   Point fixes via exact lookup on Raw BEFORE the positional logic.
   Format: <original line> => artist | title. An override fixes
   Artist/Title and shifts the dictionary tail zone by the actual core
   length (overrideCoreLength, fallback 2 tokens).

2. dicts/artists.txt
   Whitelist of hyphenated artists (Iku-Turso, Post-Mortem, ...): a
   [0]-[1] hit → Artist = 2 tokens, Title = [2], tail zone shifted.
   One entry covers all releases of the artist automatically.

3. Suspected title continuation
   Unknown tokens between the title zone and the tail (followed later by
   dictionary markers) no longer pollute UnknownTokens; they go to a
   dedicated TitleExtra field + warning "suspected title continuation" —
   a reviewer hint that the core is probably longer than 2 tokens.
   Catalogue numbers (parenthesized tokens) are resolved with FIRST
   priority and never land in suspects.

Type field (normalized base release type):
- new normalization map dicts/types.txt (<Base> = variants):
  EP = EP, CDS, CDM, 7INCH_VINYL, CDEP, ..., REMASTERED_EP; Demo = ...;
  Split; Single; Compilation; Collaboration (curator-extendable)
- tokens matched against edition/source are additionally normalized to
  the base type (first match wins); edition.txt/source.txt untouched,
  Source stays raw
- TypeDistribution map[string]int added to GroupStats and the report
  (console/JSON)
- OST/INSTRUMENTAL are release traits, not types; excluded from the map

Tests: old cases updated to the new suspect semantics; acceptance tests
added for overrides (Milicia_Oscura-Anti-Cristo), artist dictionary
(Iku-Turso), the suspect section, Type normalization (CDEP → EP with
raw Source=CDEP, first-match-wins) and TypeDistribution.

go build / go vet / go test ./internal/releaseparser/... — all green.
2026-09-01 13:18:40 +03:00
Yevhen Odynets f2629f002c releaseparser: move group-agnostic parser to groups/, GB doc-blocks, per-group JSON reports
Generalise the parser: the MOONBLOOD group profile is gone, the parser and
its dictionaries are now group-agnostic under internal/releaseparser/groups.

Positional model:
- [0]=Artist, [1]=Title are fixed and never matched against dictionaries, so
  titles like "Split"/"Demo"/"De" are never eaten.
- [len-1]=Group is always read from the line, not from the query key.
- [len-2]=Year when it is a 4-digit 19xx|20xx token; otherwise the slot is
  resolved as a middle-zone token (e.g. FLAC is still picked up as format).
- The middle zone is resolved via case-insensitive dictionaries in the order
  format -> source -> bit-depth -> resolution -> edition -> language ->
  catalogue number; stored values keep their original case.

Catalogue numbers: any "(...)" token in the middle zone is a catalogue number
regardless of content (digits, roman numerals, dots, underscores, mixed case);
splitting never breaks tokens inside parentheses.

Dictionaries moved to internal/releaseparser/groups/resources/dicts and
embedded via go:embed; dead profile fields (Name, ExpectedTailOrder) removed;
profile comments rewritten in British English.

File-based AnalyzeGroup/TestParseMoonblood removed — titles now come from the
database (cmd/srrdb: srrdb <releaser>..., config/secret.yaml DSN).

Validation output (cmd/srrdb):
- tmp/releaseparser/<group>.json         — per-group parsed ParsedRelease records
- tmp/releaseparser/<releaser>.report.json — per-request aggregate (same as console)
- review_unknown_tokens.txt              — unknown/warning rows for manual review

Tests updated for the positional model; added
TestParseCatalogNumberWithDotAndRoman for (DYN.3140-2) and (GOWP_DCCCLXXXIX).
go build, go vet and go test all pass.
2026-09-01 11:28:19 +03:00
Yevhen Odynets feef080d80 fix(file) Remove Printline 2026-09-01 09:19:17 +03:00
Yevhen Odynets 736921ae0b cosmetic changes 2026-09-01 09:05:27 +03:00
59 changed files with 2723 additions and 516 deletions
+6 -2
View File
@@ -12,7 +12,11 @@ fabric.properties
tmp/ tmp/
vendor/ vendor/
config/secret.yaml config/secret.yaml
config/scope/srrdb.yaml
*.md *.md
mdb mdb
internal/releaseparser/resources/* internal/releaseparser/groups/resources/*
review_unknown_tokens.txt review_unknown_tokens.txt
srrdb.exe
**/*.exe
/**/*.exe
+3 -1
View File
@@ -3,7 +3,9 @@
<component name="Go" enabled="true" /> <component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" /> <component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
+6 -8
View File
@@ -2,9 +2,11 @@ package main
import ( import (
"fmt" "fmt"
"os"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
scraper "git.kplus.net.ua/yevhen/resource-scraper/internal" scraper "git.kplus.net.ua/yevhen/resource-scraper/internal"
"git.kplus.net.ua/yevhen/resource-scraper/internal/config" "git.kplus.net.ua/yevhen/resource-scraper/internal/config"
@@ -33,14 +35,10 @@ func init() {
} }
func main() { func main() {
defer func() { code := scraper.Bootstrap()
fmt.Printf("\nExecution took %v", time.Since(st))
}()
//a, b := runtime.GOOS fmt.Printf("\nExecution took %v\n", time.Since(st))
//fmt.Printf("%v - - - \n", runtime.GOOS)
scraper.Bootstrap() // os.Exit skips deferred calls, so the timing above is printed before it.
//fmt.Printf("%v\n", viper.Get("23").([]interface{})) os.Exit(code)
// Кінґстонська стіна
} }
+49 -3
View File
@@ -5,13 +5,15 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/spf13/viper" "github.com/spf13/viper"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser/groups/moonblood" "git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser/groups"
) )
func main() { func main() {
@@ -35,7 +37,12 @@ func main() {
} }
defer dbase.Close() defer dbase.Close()
out := make(map[string]moonblood.GroupStats, len(releasers)) const outDir = "tmp/releaseparser"
if err := os.MkdirAll(outDir, 0o755); err != nil {
log.Fatal(err)
}
out := make(map[string]groups.GroupStats, len(releasers))
for _, releaser := range releasers { for _, releaser := range releasers {
var titles []string var titles []string
@@ -47,7 +54,16 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
out[releaser] = moonblood.AnalyzeTitles(titles) out[releaser] = groups.AnalyzeTitles(titles)
// Per-group parsed records keyed by the actual group read from each
// line: every release with all resolved fields for manual validation.
for group, parsed := range groups.ParseByGroup(titles) {
writeJSON(filepath.Join(outDir, safeName(group)+".json"), parsed)
}
// Per-request report identical to what is printed to the console.
writeJSON(filepath.Join(outDir, safeName(releaser)+".report.json"), out[releaser])
} }
data, err := json.MarshalIndent(out, "", " ") data, err := json.MarshalIndent(out, "", " ")
@@ -77,3 +93,33 @@ func main() {
fmt.Println(string(data)) fmt.Println(string(data))
} }
// writeJSON writes value as an indented JSON document to the given path.
func writeJSON(path string, value any) {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
log.Fatalf("marshal %s: %v", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
log.Fatalf("write %s: %v", path, err)
}
}
// safeName strips characters that are unsafe in file names.
func safeName(s string) string {
repl := func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '-' || r == '_' || r == '.':
return r
default:
return '_'
}
}
return strings.Map(repl, s)
}
+1 -1
View File
@@ -1,5 +1,5 @@
scope: scope:
allow: [info, web, rutracker, prescene, stb, metal-archives, console] allow: [info, web, rutracker, prescene, stb, metal-archives, srrdb, console]
default: console default: console
mail: mail:
kplus.net.ua: kplus.net.ua:
+2
View File
@@ -3,6 +3,7 @@ endpoint: https://scnlog.me/music/flac/
endpoint-next: page/%s/ endpoint-next: page/%s/
sleep-before-next-iteration: 5s sleep-before-next-iteration: 5s
levels-to-scrape: 6 levels-to-scrape: 6
nfo_dir: /var/lib/mdb/dump/nfo
groups: groups:
tags: tags:
neformat: neformat:
@@ -41,6 +42,7 @@ groups:
- FLACME - FLACME
- FrB - FrB
- HOUND - HOUND
- JADED
- JRO - JRO
- KINDA - KINDA
- MAHOU - MAHOU
+23 -15
View File
@@ -1,43 +1,51 @@
module git.kplus.net.ua/yevhen/resource-scraper module git.kplus.net.ua/yevhen/resource-scraper
go 1.26.2 go 1.27
require ( require (
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/dromara/carbon/v2 v2.6.16 github.com/dromara/carbon/v2 v2.6.17
github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.3.2
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.10.1
github.com/iancoleman/strcase v0.3.0 github.com/iancoleman/strcase v0.3.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/logrusorgru/aurora/v4 v4.0.0 github.com/logrusorgru/aurora/v4 v4.0.0
github.com/mewkiz/flac v1.0.13 github.com/mewkiz/flac v1.0.14
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba
golang.org/x/net v0.49.0 golang.org/x/net v0.59.0
modernc.org/sqlite v1.60.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/icza/bitio v1.1.0 // indirect github.com/icza/bitio v1.1.0 // indirect
github.com/mewkiz/pkg v0.0.0-20260331151047-10214ccde7de // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mewkiz/pkg v0.0.0-20260703220044-4fb89b18cc87 // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
) )
+74 -62
View File
@@ -1,12 +1,14 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dromara/carbon/v2 v2.6.16 h1:AbxrnW1kJhR3KHdS8G96NFmxDwPFyre+t+xSiJIUD1I= github.com/dromara/carbon/v2 v2.6.17 h1:QL1LVcp5A/NQtIPbc1vrL9qIXxPNWnaDOBaFlmP1iDg=
github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dromara/carbon/v2 v2.6.17/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
@@ -15,23 +17,29 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
@@ -48,18 +56,24 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA=
github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= github.com/mewkiz/flac v1.0.14 h1:hyRGAM8NCKznoPmIi9zz2jyO+nfmxY2ErqBnHZ+gxh4=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= github.com/mewkiz/flac v1.0.14/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20260331151047-10214ccde7de h1:tVseKKgTIPOo8L0gFK4qX+kqyINtWncwER/t1n2im1A= github.com/mewkiz/pkg v0.0.0-20260703220044-4fb89b18cc87 h1:fpWNLIg7nX2f+D+6ZveQ5hfkTjq5WgMBtYbz5Nb1w0Y=
github.com/mewkiz/pkg v0.0.0-20260331151047-10214ccde7de/go.mod h1:omNJr4dHOKbrBeoY/idmLDFw8OIdDUUZvj3uB1cJxwA= github.com/mewkiz/pkg v0.0.0-20260703220044-4fb89b18cc87/go.mod h1:wohiqwTwXA5twjDES74xIJLGjAxo3/0pHSbTXLbSDW8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
@@ -77,81 +91,79 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.0 h1:7AZh8lREDo8x3j7aSdF7KGpAKUkJExJ1p67tcRnmttM=
modernc.org/sqlite v1.60.0/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#go build -trimpath -ldflags="-s -w" -o /usr/local/bin/mdb ./cmd #go build -trimpath -ldflags="-s -w" -o /usr/local/bin/mdb ./cmd
go build -trimpath -ldflags="-s -w" -o /opt/sources/resource-scraper/cmd/mdb ./cmd go build -trimpath -ldflags="-s -w" -o /opt/sources/resource-scraper/mdb ./cmd
+34 -5
View File
@@ -2,8 +2,10 @@ package scraper
import ( import (
"fmt" "fmt"
"log/slog"
"github.com/iancoleman/strcase" "github.com/iancoleman/strcase"
"github.com/jmoiron/sqlx"
"github.com/logrusorgru/aurora/v4" "github.com/logrusorgru/aurora/v4"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
@@ -26,22 +28,49 @@ func init() {
strcase.ConfigureAcronym("stb", "STB") strcase.ConfigureAcronym("stb", "STB")
} }
func Bootstrap() { // Bootstrap runs the selected scope and returns the process exit code: zero on
// success, non-zero as soon as the scope reports a failure.
func Bootstrap() int {
if !isAllowScope() { if !isAllowScope() {
fmt.Printf("%s You are in not allowed scope, check %s config file\n", aurora.BgMagenta("[WARN]"), aurora.Magenta("default.yaml")) fmt.Printf("%s You are in not allowed scope, check %s config file\n", aurora.BgMagenta("[WARN]"), aurora.Magenta("default.yaml"))
return
return 2
} }
dbase := db.New() dbase := db.New()
repos := repository.New(dbase)
// The SQLite handle is opened only for the srrdb scope, so that a missing
// or unreadable srrdb.sqlite does not break the other sources.
var srrdb *sqlx.DB
if viper.GetString(constant.FlagScopeEnable) == constant.ScopeSrrdb {
srrdb = db.NewSrrdb()
}
repos := repository.New(dbase, srrdb)
services := service.New(repos) services := service.New(repos)
handlers := handler.New(services) handlers := handler.New(services)
switch viper.GetString("role") { switch viper.GetString("role") {
case constant.RoleConsole: case constant.RoleConsole:
fmt.Printf("init console console: %s\n", handlers.InitConsole()) result, err := handlers.InitConsole()
fmt.Printf("init console console: %s\n", result)
if err != nil {
slog.Error("console command failed", "scope", viper.GetString(constant.FlagScopeEnable), "err", err)
return 1
}
case constant.RoleWeb: case constant.RoleWeb:
fmt.Printf("who: %s\n", handlers.InitRoutes()) result, err := handlers.InitRoutes()
fmt.Printf("who: %s\n", result)
///http.Run() ///http.Run()
if err != nil {
slog.Error("web init failed", "err", err)
return 1
}
} }
return 0
} }
+60
View File
@@ -0,0 +1,60 @@
package db
import (
_ "embed"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/spf13/viper"
)
// srrdbSchema is the DDL of the srrdb database. Keeping it in a file lets the
// schema be read and edited without touching Go code; go:embed pulls it into the
// binary at build time.
//
//go:embed srrdb.sql
var srrdbSchema string
// NewSrrdb opens the separate SQLite database that holds srrdb_releases.
// The driver name is read from db.ext.driver (sqlite, modernc.org/sqlite), so
// the driver can be replaced in configuration without touching this file.
func NewSrrdb() *sqlx.DB {
driver := viper.GetString("srrdb.driver")
path := viper.GetString("srrdb.database")
db, err := sqlx.Connect(driver, path)
if err != nil {
panic("Failed to connect to the srrdb database: " + err.Error())
}
if err = db.Ping(); err != nil {
panic("Failed to ping the srrdb database: " + err.Error())
}
if err = srrdbCreate(db); err != nil {
panic("Failed to create the srrdb schema: " + err.Error())
}
return db
}
// srrdbCreate applies the embedded DDL statement by statement. Every statement is
// written to be repeatable, so an existing database is left alone: this creates
// missing objects, it does not alter existing ones.
//
// Splitting on ";" is safe for this schema because no statement holds a semicolon
// inside a literal.
func srrdbCreate(db *sqlx.DB) error {
for _, statement := range strings.Split(srrdbSchema, ";") {
if statement = strings.TrimSpace(statement); statement == "" {
continue
}
if _, err := db.Exec(statement); err != nil {
return fmt.Errorf("apply srrdb schema statement %q: %w", statement, err)
}
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS srrdb_releases
(
"release" TEXT PRIMARY KEY NOT NULL,
date TEXT,
created_at TEXT NOT NULL,
synced_at TEXT,
category TEXT NOT NULL DEFAULT '',
status TEXT,
group_name TEXT,
has_nfo TEXT,
has_srs TEXT,
is_foreign TEXT,
imdb_id TEXT,
size INTEGER,
comment TEXT,
nfo BLOB,
content TEXT,
source TEXT,
CHECK (
status IN (
'on', 'hdd', 'hdd_e', 'catalogued',
'deferred', 'ext', 'cloud', 'burnt', 'updated',
'onhold', 'watch', 'hidden', 'duplicate', 'trash',
'closed', 'request', '2cat', '2compare', 'neformat',
'sf88', 'BATA', 'away', 'ready2burn', 'accurip',
'replace', '2burn', 'remove', 'paid', 'free', 'ftp',
'stream', 'ai'
)
)
);
CREATE INDEX IF NOT EXISTS idx_srrdb_date
ON srrdb_releases (date);
CREATE INDEX IF NOT EXISTS idx_srrdb_group
ON srrdb_releases (group_name);
CREATE INDEX IF NOT EXISTS idx_srrdb_status
ON srrdb_releases (status);
CREATE TABLE IF NOT EXISTS srrdb_log
(
day TEXT NOT NULL,
category TEXT NOT NULL,
found INTEGER NOT NULL,
stored INTEGER NOT NULL,
ignored INTEGER NOT NULL,
run_at TEXT NOT NULL,
PRIMARY KEY (day, category)
);
+2 -1
View File
@@ -42,7 +42,8 @@ func ParseFlags() {
flag.String(constant.FlagScopeEnable, "", "") flag.String(constant.FlagScopeEnable, "", "")
flag.String(constant.FlagSingleUri, "", "") flag.String(constant.FlagSingleUri, "", "")
flag.String(constant.FlagEnv, defaultConfigEnv, "") flag.String(constant.FlagEnv, defaultConfigEnv, "")
flag.String("create", defaultConfigEnv, "used to create e.g. scope etc.") flag.String(constant.FlagCreate, defaultConfigEnv, "used to create e.g. scope etc.")
flag.Duration(constant.FlagSleep, 0, "pause between requests, e.g. 2s")
pflag.CommandLine.AddGoFlagSet(flag.CommandLine) pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
pflag.Parse() pflag.Parse()
@@ -1,8 +1,6 @@
package moonblood package groups
import ( import (
"bufio"
"os"
"sort" "sort"
"strings" "strings"
@@ -18,6 +16,7 @@ type GroupStats struct {
SourceDistribution map[string]int SourceDistribution map[string]int
BitDepthDistribution map[string]int BitDepthDistribution map[string]int
ResolutionDistribution map[string]int ResolutionDistribution map[string]int
TypeDistribution map[string]int
LanguageDistribution map[string]int LanguageDistribution map[string]int
EditionDistribution map[string]int EditionDistribution map[string]int
CommentDistribution map[string]int CommentDistribution map[string]int
@@ -26,37 +25,13 @@ type GroupStats struct {
AvgConfidence float64 AvgConfidence float64
} }
// AnalyzeGroup parses the release lines from the local resource file. It is
// used for a quick local check of the dictionaries without touching a
// database.
func AnalyzeGroup() (GroupStats, error) {
file, err := os.Open("../../resources/moonblood.txt")
if err != nil {
return GroupStats{}, err
}
defer file.Close()
var titles []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
titles = append(titles, line)
}
}
if err := scanner.Err(); err != nil {
return GroupStats{}, err
}
return AnalyzeTitles(titles), nil
}
// AnalyzeTitles parses a set of release lines (for example fetched from the // AnalyzeTitles parses a set of release lines (for example fetched from the
// database by a particular releaser key) and aggregates the results into a // database by a particular releaser key) and aggregates the results into a
// GroupStats summary. // GroupStats summary.
func AnalyzeTitles(titles []string) GroupStats { func AnalyzeTitles(titles []string) GroupStats {
stats := GroupStats{ stats := GroupStats{
YearDistribution: make(map[int]int), YearDistribution: make(map[int]int),
TypeDistribution: make(map[string]int),
SourceDistribution: make(map[string]int), SourceDistribution: make(map[string]int),
BitDepthDistribution: make(map[string]int), BitDepthDistribution: make(map[string]int),
ResolutionDistribution: make(map[string]int), ResolutionDistribution: make(map[string]int),
@@ -99,6 +74,10 @@ func AnalyzeTitles(titles []string) GroupStats {
} }
} }
if result.Type != "" {
stats.TypeDistribution[result.Type]++
}
for _, unknown := range result.UnknownTokens { for _, unknown := range result.UnknownTokens {
if _, exists := unknownMap[unknown]; !exists { if _, exists := unknownMap[unknown]; !exists {
unknownMap[unknown] = &releaseparser.UnknownTokenStats{ unknownMap[unknown] = &releaseparser.UnknownTokenStats{
@@ -131,7 +110,6 @@ func AnalyzeTitles(titles []string) GroupStats {
for _, tokenStats := range unknownMap { for _, tokenStats := range unknownMap {
stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats) stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats)
} }
sort.Slice(stats.UnknownTokens, func(i, j int) bool { sort.Slice(stats.UnknownTokens, func(i, j int) bool {
return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count
}) })
@@ -149,3 +127,34 @@ func AnalyzeTitles(titles []string) GroupStats {
return stats return stats
} }
// AnalyzeByGroup parses a set of release lines and returns per-group
// statistics keyed by the actual group read from each line (not from the
// query key, which may cover several groups). It is used to validate parsing
// quality against the original data, grouped by the real releaser name.
func AnalyzeByGroup(titles []string) map[string]GroupStats {
byGroup := make(map[string][]string)
for _, title := range titles {
group := Parse(title).Group
byGroup[group] = append(byGroup[group], title)
}
out := make(map[string]GroupStats, len(byGroup))
for group, lines := range byGroup {
out[group] = AnalyzeTitles(lines)
}
return out
}
// ParseByGroup parses a set of release lines into a per-group list of fully
// populated ParsedRelease records, keyed by the actual group read from each
// line. It is used to validate parsing quality field-by-field against the
// original data.
func ParseByGroup(titles []string) map[string][]releaseparser.ParsedRelease {
byGroup := make(map[string][]releaseparser.ParsedRelease)
for _, title := range titles {
result := Parse(title)
byGroup[result.Group] = append(byGroup[result.Group], result)
}
return byGroup
}
@@ -1,5 +0,0 @@
# {bit_depth}
16BIT
24BIT
32BIT
DE16BIT
@@ -1,6 +0,0 @@
REPACK
REAL_REPACK
READNFO
PROPER
RERIP
199X
@@ -1,70 +0,0 @@
# {edition} — тип видання
EP
DEMO
SPLIT
REISSUE
DELUXE_EDITION
REMASTERED
BOXSET
SINGLE
PROMO
REISSUE_REMASTERED
REMASTERED_25TH_ANNIVERSARY_EDITION
REMASTERED_20TH_ANNIVERSARY_EDITION
20TH_ANNIVERSARY_EDITION_REMASTERED
Deluxe_Edition
LIMITED_EDITION_BOXSET
REMASTERED_EP
10TH_ANNIVERSARY_EDITION
OST
XX_Legacy_Edition
REISSUE_DEMO
JP_Deluxe_Edition
Remastered_Edition
Anniversary_Edition
Limited_Edition
CDS
CDM
RETAIL
JP_RETAIL
JP_REISSUE
DEMO_REMASTERED
EU_RETAIL
DE_RETAIL
REISSUE_DELUXE_EDITION
RETAIL_REISSUE
SPECIAL_EDITION
SPECIAL_COLLECTORS_EDITION_REMASTERED
30_YEARS_SPECIAL_COLLECTORS_EDITION_REMASTERED
LIMITED_EDITION_REMASTERED
25th_Anniversary_Edition
DEMO_REISSUE
Ultimate_Edition
Issue_255
Issue_254
Issue_198
7INCH_VINYL
REMASTERED_REISSUE
PROMO_DEMO
ES_RETAIL
BOX_SET
BONUS
REISSUE_EP
30th_Anniversary
REDUX
REMASTERED_BOXSET
MAG
REISSUE_BOXSET
30TH_ANNIVERSARY_EDITION_REMASTERED
DELUXE
Deluxe_Edition_Reissue
INSTRUMENTAL
REISSUE_EXPANDED_EDITION
BOOTLEG
EXPANDED_EDITION
ADVANCE
15TH_ANNIVERSARY_DELUXE_EDITION
TOUR_EDITION
REMASTERED_DEMO
REMASTERED_DELUXE_EDITION
30TH_ANNIVERSARY_EDITION
@@ -1,3 +0,0 @@
# {format} — аудіоформат
# один токен на рядок, регістр не має значення
FLAC
@@ -1,38 +0,0 @@
# {language} — мовні маркери
DE
FR
ES
SK
FI
SE
BR
PL
NL
NO
PT
IT
HU
DK
AT
IS
UA
JP
GR
CN
RU
ID
PH
RO
LT
BY
CZ
SP
IR
FO
RS
DA
HI
CH
LU
CY
EE
@@ -1,8 +0,0 @@
# {resolution} — частота дискретизації
44KHZ
48KHZ
48.0kHz
88KHZ
96KHZ
176KHZ
192KHZ
@@ -1,36 +0,0 @@
# {source} — джерело релізу
# VINYL сюди входить як source (за домовленістю, не edition)
WEB
CD
2CD
CDEP
VINYL
TAPE
CDR
17CD
3CD
4CD
18CD
3CDEP
DVD
7CD
2CDR
10CD
6CD
2CDEP
2VINYL
CDREP
LP
2LP
3VINYL
3TAPE
13CD
13CDR
10INCH_VINYL
25CD
WEBFLAC
2TAPE
BLURAY
VLS
MCD
24BIT_WEB
@@ -1,42 +0,0 @@
package moonblood
import (
"embed"
"strings"
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser"
)
// Словники токенів лежать у dicts/*.txt — один токен на рядок.
// Їх можна редагувати без перекомпіляції логіки (embed тягне їх у бінарник на збірці).
//go:embed dicts/*.txt
var dictFS embed.FS
func loadDict(name string) map[string]bool {
data, err := dictFS.ReadFile("dicts/" + name + ".txt")
if err != nil {
panic("moonblood: cannot load dictionary " + name + ": " + err.Error())
}
out := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.ToUpper(strings.TrimSpace(line))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out
}
var Profile = releaseparser.GroupProfile{
Name: "MOONBLOOD",
ExpectedTailOrder: []string{"bit_depth", "resolution", "source", "format", "year", "group"},
FormatTokens: loadDict("format"),
SourceTokens: loadDict("source"),
BitDepthTokens: loadDict("bitdepth"),
ResolutionTokens: loadDict("resolution"),
LanguageMarkers: loadDict("language"),
EditionTokens: loadDict("edition"),
CommentTokens: loadDict("comment"),
}
@@ -1,4 +1,4 @@
package moonblood package groups
import ( import (
"regexp" "regexp"
@@ -56,20 +56,76 @@ func Parse(raw string) releaseparser.ParsedRelease {
return result return result
} }
// Fixed positional edges: [0]=Artist, [1]=Title, [len-1]=Group, // Left core boundary: after the resolved Artist (2 tokens by default,
// [len-2]=Year (when present). // 3 with an artist dictionary hit, or fixed by an override).
result.Artist = tokens[0] start := 2
if len(tokens) > 1 {
result.Title = tokens[1] // Level 1: exact-lookup override table fixes Artist/Title only;
// the tail zone is parsed as usual below. The override is
// authoritative about the core: leftover tokens are plain unknowns,
// not part of the title.
if o, ok := Profile.Overrides[result.Raw]; ok {
result.Artist, result.Title = o.Artist, o.Title
start = overrideCoreLength(tokens, o)
} }
// Level 2: artist dictionary — a hyphenated artist may span ANY number
// of leading tokens ("Shadu-Nar-Mattaru" = 3). The LONGEST whitelisted
// prefix wins; the title is the token right after it. At least one
// title token and one tail token must remain.
artistDict := false
if result.Artist == "" {
for k := len(tokens) - 2; k >= 2; k-- {
if Profile.ArtistTokens[strings.ToUpper(strings.Join(tokens[:k], "-"))] {
artistDict = true
start = k + 1
break
}
}
}
result.Group = tokens[len(tokens)-1] result.Group = tokens[len(tokens)-1]
end := len(tokens) - 1 end := len(tokens) - 1
if len(tokens) >= 3 && yearRegexp.MatchString(tokens[len(tokens)-2]) { if len(tokens) >= 3 && yearRegexp.MatchString(tokens[len(tokens)-2]) {
result.Year, _ = strconv.Atoi(tokens[len(tokens)-2]) result.Year, _ = strconv.Atoi(tokens[len(tokens)-2])
end = len(tokens) - 2 end = len(tokens) - 2
} }
// Resolve the middle zone tokens via the dictionaries.
parseTailZone(tokens[2:end], &result) // Right core boundary: the first dictionary-known token after the
// resolved artist. The boundary is contextual, but the title itself
// stays a single token ([1], or [2] with an artist dictionary hit).
// Unknown tokens inside the core region ([start .. tailStart)) are
// REPORTED in UnknownTokens — never eaten, never glued into the title.
tailStart := end
for i := start; i < end; i++ {
if isKnownToken(tokens[i]) {
tailStart = i
break
}
}
// Resolve Artist/Title from the core unless the override already did.
if result.Artist == "" {
if artistDict {
// The artist spans tokens [0 .. start-2]; re-join them.
result.Artist = strings.Join(tokens[:start-1], "-")
} else {
result.Artist = tokens[0]
}
if len(tokens) > start-1 {
result.Title = tokens[start-1]
}
}
// Core-region leftovers (e.g. the rest of a wildcards title after an
// override, or a hyphenated title fragment without an artist entry):
// visible in UnknownTokens for review.
for _, tok := range tokens[start:tailStart] {
result.UnknownTokens = append(result.UnknownTokens, tok)
}
// Level 3: dictionary-driven tail zone, starting after the resolved core.
parseTailZone(tokens[tailStart:end], &result)
score(&result) score(&result)
return result return result
@@ -110,7 +166,7 @@ func split(raw string) []string {
// list and returns the cleaned list together with the removed comments. // list and returns the cleaned list together with the removed comments.
func extractComments(tokens []string) ([]string, []string) { func extractComments(tokens []string) ([]string, []string) {
out := make([]string, 0, len(tokens)) out := make([]string, 0, len(tokens))
comments := []string{} var comments []string
for _, tok := range tokens { for _, tok := range tokens {
if Profile.CommentTokens[strings.ToUpper(tok)] { if Profile.CommentTokens[strings.ToUpper(tok)] {
comments = append(comments, tok) comments = append(comments, tok)
@@ -135,9 +191,21 @@ func catalogNumber(token string) string {
return token[1 : len(token)-1] return token[1 : len(token)-1]
} }
// isKnownToken reports whether the token belongs to the tail zone: it
// matches one of the profile dictionaries or is a parenthesized catalogue
// number. The first such token after the core marks the right core
// boundary.
func isKnownToken(tok string) bool {
up := strings.ToUpper(tok)
return Profile.FormatTokens[up] || Profile.SourceTokens[up] ||
Profile.BitDepthTokens[up] || Profile.ResolutionTokens[up] ||
Profile.EditionTokens[up] || Profile.LanguageMarkers[up] ||
catalogNumber(tok) != ""
}
// parseTailZone resolves middle-zone tokens against the dictionaries. // parseTailZone resolves middle-zone tokens against the dictionaries.
// The first match wins for each field; catalogue numbers are recognised // The first match wins for each field; catalogue numbers are recognized
// from parenthesized tokens, and anything unrecognised is appended to // from parenthesized tokens, and anything unrecognized is appended to
// UnknownTokens so that it can be reviewed and curated into a dictionary. // UnknownTokens so that it can be reviewed and curated into a dictionary.
func parseTailZone(zone []string, result *releaseparser.ParsedRelease) { func parseTailZone(zone []string, result *releaseparser.ParsedRelease) {
for _, tok := range zone { for _, tok := range zone {
@@ -151,6 +219,7 @@ func parseTailZone(zone []string, result *releaseparser.ParsedRelease) {
if result.Source == "" { if result.Source == "" {
result.Source = tok result.Source = tok
} }
applyType(tok, result)
case Profile.BitDepthTokens[up]: case Profile.BitDepthTokens[up]:
if result.BitDepth == "" { if result.BitDepth == "" {
result.BitDepth = tok result.BitDepth = tok
@@ -163,6 +232,7 @@ func parseTailZone(zone []string, result *releaseparser.ParsedRelease) {
if result.Edition == "" { if result.Edition == "" {
result.Edition = tok result.Edition = tok
} }
applyType(tok, result)
case Profile.LanguageMarkers[up]: case Profile.LanguageMarkers[up]:
if result.Language == "" { if result.Language == "" {
result.Language = tok result.Language = tok
@@ -177,6 +247,33 @@ func parseTailZone(zone []string, result *releaseparser.ParsedRelease) {
} }
} }
// applyType normalizes a matched edition/source token into ParsedRelease.Type
// via the types.txt variant map; first match wins. The token still fills its
// own raw field (Source stays raw).
func applyType(tok string, result *releaseparser.ParsedRelease) {
if base, ok := Profile.TypeVariants[strings.ToUpper(tok)]; ok && result.Type == "" {
result.Type = base
}
}
// overrideCoreLength returns the number of leading tokens covered by the
// override's Artist and Title (each may span several hyphen-separated
// tokens), falling back to the default 2-token core when the raw tokens
// do not reconstruct the override fields.
func overrideCoreLength(tokens []string, o releaseparser.Override) int {
for a := 1; a <= len(tokens); a++ {
if strings.Join(tokens[:a], "-") != o.Artist {
continue
}
for t := a + 1; t <= len(tokens); t++ {
if strings.Join(tokens[a:t], "-") == o.Title {
return t
}
}
}
return 2
}
// score computes a heuristic confidence in [0,1] for a parsed release based // score computes a heuristic confidence in [0,1] for a parsed release based
// on how many core fields (group, year, format, source, artist) were resolved // on how many core fields (group, year, format, source, artist) were resolved
// and whether any warnings or unknown tokens are present. // and whether any warnings or unknown tokens are present.
@@ -1,43 +1,9 @@
package moonblood package groups
import ( import (
"bufio"
"os"
"testing" "testing"
) )
func TestParseMoonblood(t *testing.T) {
file, err := os.Open("../../resources/moonblood.txt")
if err != nil {
t.Skipf("resource file not found (DB mode), skipping: %v", err)
t.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
total, success := 0, 0
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
result := Parse(line)
total++
if result.Group == "MOONBLOOD" && result.Year != 0 && result.Format == "FLAC" {
success++
}
if result.Confidence < 0.7 {
t.Logf("Low confidence: %s (%.2f)", line, result.Confidence)
}
}
t.Logf("Parsed %d releases, %d successful (%.1f%%)", total, success, float64(success)/float64(total)*100)
}
func TestParseFullTail(t *testing.T) { func TestParseFullTail(t *testing.T) {
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
if r.Artist != "Fane" { if r.Artist != "Fane" {
@@ -71,11 +37,15 @@ func TestParseEditionAndDemo(t *testing.T) {
} }
func TestParseArtistWithHyphen(t *testing.T) { func TestParseArtistWithHyphen(t *testing.T) {
// [0]=Artist, [1]=Title. Artist сам може містити дефіс — тоді тайтл // Artist сам містить дефіс, але Ultra-Violence ще не в словнику:
// стає токеном [1], а {"Violence", "All_My_Life"} щось лишається unknown. // core контекстуальний — все між артистом і першим словниковим
// маркером (EP) є назвою, зібраною назад через "-".
r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD") r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD")
if r.Artist != "Ultra" || r.Title != "Violence" { if r.Artist != "Ultra" {
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence", r.Artist, r.Title) t.Errorf("Artist = %q, want Ultra", r.Artist)
}
if r.Title != "Violence" {
t.Errorf("Title = %q, want Violence", r.Title)
} }
if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 { if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 {
t.Errorf("tail fields wrong: %+v", r) t.Errorf("tail fields wrong: %+v", r)
@@ -157,12 +127,16 @@ func TestParseCaseInsensitiveDictionaries(t *testing.T) {
} }
func TestParseUnknownTailTokenReported(t *testing.T) { func TestParseUnknownTailTokenReported(t *testing.T) {
// Невідомий токен між двома відомими маркерами → у UnknownTokens. // Невідомий токен ПІСЛЯ останнього словникового маркера (за ним уже
r := Parse("Artist-Some_Title-SPLIT-FOO-16BIT-WEB-FLAC-2025-MOONBLOOD") // нічого відомого немає) → у UnknownTokens.
r := Parse("Artist-Some_Title-16BIT-WEB-FLAC-FOO-2025-MOONBLOOD")
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" { if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" {
t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens) t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens)
} }
if r.Edition != "SPLIT" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 { if len(r.TitleExtra) != 0 {
t.Errorf("TitleExtra = %v, want empty", r.TitleExtra)
}
if r.Edition != "" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 {
t.Errorf("tail fields wrong: %+v", r) t.Errorf("tail fields wrong: %+v", r)
} }
if r.Artist != "Artist" || r.Title != "Some_Title" { if r.Artist != "Artist" || r.Title != "Some_Title" {
@@ -170,6 +144,25 @@ func TestParseUnknownTailTokenReported(t *testing.T) {
} }
} }
func TestParseContextualCoreAbsorbsTitleTail(t *testing.T) {
// Права межа core контекстуальна (перший словниковий маркер), але
// невідомі токени всередині core-зони — в UnknownTokens, не з'їдаються.
r := Parse("Artist-Some_Title-Part_Two-Part_Three-16BIT-WEB-FLAC-2025-MOONBLOOD")
if r.Artist != "Artist" {
t.Errorf("Artist = %q, want Artist", r.Artist)
}
if r.Title != "Some_Title" {
t.Errorf("Title = %q, want Some_Title", r.Title)
}
if r.BitDepth != "16BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2025 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 2 || r.UnknownTokens[0] != "Part_Two" ||
r.UnknownTokens[1] != "Part_Three" {
t.Errorf("UnknownTokens = %v, want [Part_Two Part_Three]", r.UnknownTokens)
}
}
func TestParseCoreTokensNeverReported(t *testing.T) { func TestParseCoreTokensNeverReported(t *testing.T) {
// Артист/тайтл ніколи не потрапляють в UnknownTokens. // Артист/тайтл ніколи не потрапляють в UnknownTokens.
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
@@ -287,8 +280,8 @@ func TestParseCatalogNumberWithDotAndRoman(t *testing.T) {
} }
func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) { func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) {
// [1]=Title (фіксовано), а Evolution — не словниковий токен у середині: // Evolution — не словниковий токен у core-зоні → в UnknownTokens,
// потрапляє в UnknownTokens. (SMG409) — катало. // не з'їдається. (SMG409) — катало, перший відомий маркер.
r := Parse("Ferosity-De-Evolution-(SMG409)-CD-FLAC-2025-86D") r := Parse("Ferosity-De-Evolution-(SMG409)-CD-FLAC-2025-86D")
if r.CatalogNo != "SMG409" { if r.CatalogNo != "SMG409" {
t.Errorf("CatalogNo = %q, want SMG409", r.CatalogNo) t.Errorf("CatalogNo = %q, want SMG409", r.CatalogNo)
@@ -364,7 +357,8 @@ func TestParseCompoundEditionRecognized(t *testing.T) {
} }
func TestParseMixedCaseTitleNeverReported(t *testing.T) { func TestParseMixedCaseTitleNeverReported(t *testing.T) {
// [1]=Title (один токен), решта, що не словник — UnknownTokens. // Хвіст назви (_Sky_Burial) — не словник, але він ДО першого маркера
// (катало (RTM103)) → частина назви, не UnknownTokens.
r := Parse("Enthrallment-Against_the_Will_to_Live_-_Sky_Burial-(RTM103)-CD-FLAC-2021-86D") r := Parse("Enthrallment-Against_the_Will_to_Live_-_Sky_Burial-(RTM103)-CD-FLAC-2021-86D")
if r.Artist != "Enthrallment" || r.Title != "Against_the_Will_to_Live_" { if r.Artist != "Enthrallment" || r.Title != "Against_the_Will_to_Live_" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
@@ -372,6 +366,9 @@ func TestParseMixedCaseTitleNeverReported(t *testing.T) {
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "_Sky_Burial" { if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "_Sky_Burial" {
t.Errorf("UnknownTokens = %v, want [_Sky_Burial]", r.UnknownTokens) t.Errorf("UnknownTokens = %v, want [_Sky_Burial]", r.UnknownTokens)
} }
if r.CatalogNo != "RTM103" {
t.Errorf("CatalogNo = %q, want RTM103", r.CatalogNo)
}
} }
func contains(list []string, s string) bool { func contains(list []string, s string) bool {
@@ -399,3 +396,99 @@ func TestParseParenthesizedTitleNotCatNo(t *testing.T) {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
} }
} }
func TestParseArtistDictionaryCoversReleases(t *testing.T) {
// artists.txt: "Iku-Turso" у словнику → Artist = 2 токени,
// Title = [2], хвостова зона зсувається і парситься як завжди.
r := Parse("Iku-Turso-Ikuinen_Steelfest_MMXXII_Live-24BIT-WEB-FLAC-2016-MOONBLOOD")
if r.Artist != "Iku-Turso" {
t.Errorf("Artist = %q, want Iku-Turso", r.Artist)
}
if r.Title != "Ikuinen_Steelfest_MMXXII_Live" {
t.Errorf("Title = %q, want Ikuinen_Steelfest_MMXXII_Live", r.Title)
}
if r.BitDepth != "24BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2016 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 {
t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty",
r.UnknownTokens, r.TitleExtra, r.Warnings)
}
}
func TestParseArtistDictionaryMultiTokenArtist(t *testing.T) {
// artists.txt: "Shadu-Nar-Mattaru" — артист з ТРЬОХ токенів.
// Найдовший словниковий префікс виграє: Artist = 3 токени,
// Title = наступний токен, хвіст парситься як завжди.
r := Parse("Shadu-Nar-Mattaru-On_Pathways_Macabre-24BIT-WEB-FLAC-2023-MOONBLOOD")
if r.Artist != "Shadu-Nar-Mattaru" {
t.Errorf("Artist = %q, want Shadu-Nar-Mattaru", r.Artist)
}
if r.Title != "On_Pathways_Macabre" {
t.Errorf("Title = %q, want On_Pathways_Macabre", r.Title)
}
if r.BitDepth != "24BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2023 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 {
t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty",
r.UnknownTokens, r.TitleExtra, r.Warnings)
}
}
func TestParseOverrideWinsOverPositionalModel(t *testing.T) {
// overrides.txt (найвищий пріоритет): точний lookup по Raw
// фіксує Artist/Title; хвіст парситься як завжди.
raw := "Milicia_Oscura-Anti-Cristo-ES-16BIT-WEB-FLAC-2013-MOONBLOOD"
r := Parse(raw)
if r.Artist != "Milicia_Oscura" {
t.Errorf("Artist = %q, want Milicia_Oscura", r.Artist)
}
if r.Title != "Anti-Cristo" {
t.Errorf("Title = %q, want Anti-Cristo", r.Title)
}
if r.Language != "ES" || r.BitDepth != "16BIT" || r.Source != "WEB" ||
r.Format != "FLAC" || r.Year != 2013 || r.Group != "MOONBLOOD" {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 {
t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty",
r.UnknownTokens, r.TitleExtra, r.Warnings)
}
}
func TestParseTypeNormalizedSourceStaysRaw(t *testing.T) {
// CDEP у source.txt: Source лишається сирим, Type нормалізується в EP.
r := Parse("Black_Ceremonial_Kult-Crowned_in_Chaos-CDEP-FLAC-2021-MOONBLOOD")
if r.Type != "EP" {
t.Errorf("Type = %q, want EP", r.Type)
}
if r.Source != "CDEP" {
t.Errorf("Source = %q, want raw CDEP", r.Source)
}
}
func TestParseTypeFirstMatchWins(t *testing.T) {
// REMASTERED_EP → EP; наступний типовий токен не перезаписує.
r := Parse("Some_Artist-Some_Title-REMASTERED_EP-SINGLE-FLAC-2021-MOONBLOOD")
if r.Type != "EP" {
t.Errorf("Type = %q, want EP (first match wins)", r.Type)
}
if r.Edition != "REMASTERED_EP" {
t.Errorf("Edition = %q, want REMASTERED_EP", r.Edition)
}
}
func TestAnalyzeTypeDistribution(t *testing.T) {
stats := AnalyzeTitles([]string{
"Some_Artist-Some_Title-CDEP-FLAC-2021-MOONBLOOD",
"Other_Artist-Other_Title-EP-FLAC-2022-MOONBLOOD",
"Third_Artist-Third_Title-SPLIT-FLAC-2023-MOONBLOOD",
})
if stats.TypeDistribution["EP"] != 2 {
t.Errorf("TypeDistribution[EP] = %d, want 2", stats.TypeDistribution["EP"])
}
if stats.TypeDistribution["Split"] != 1 {
t.Errorf("TypeDistribution[Split] = %d, want 1", stats.TypeDistribution["Split"])
}
}
+104
View File
@@ -0,0 +1,104 @@
package groups
import (
"embed"
"strings"
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser"
)
// The token dictionaries live under resources/dicts/*.txt — one token (in any
// case) per line; blank lines and lines starting with "#" are ignored. They
// can be edited without recompiling the parsing logic: go:embed pulls them
// into the binary at build time.
//go:embed resources/dicts/*.txt
var dictFS embed.FS
func loadDict(name string) map[string]bool {
data, err := dictFS.ReadFile("resources/dicts/" + name + ".txt")
if err != nil {
panic("release parser: cannot load dictionary " + name + ": " + err.Error())
}
out := make(map[string]bool)
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.ToUpper(strings.TrimSpace(line))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out
}
// loadOverrides parses the override table: "<raw line> => artist | title".
func loadOverrides(name string) map[string]releaseparser.Override {
data, err := dictFS.ReadFile("resources/dicts/" + name + ".txt")
if err != nil {
panic("release parser: cannot load dictionary " + name + ": " + err.Error())
}
out := make(map[string]releaseparser.Override)
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
head, rest, ok := strings.Cut(line, "=>")
if !ok {
continue
}
artist, title, ok := strings.Cut(strings.TrimSpace(rest), "|")
if !ok {
continue
}
out[strings.TrimSpace(head)] = releaseparser.Override{
Artist: strings.TrimSpace(artist),
Title: strings.TrimSpace(title),
}
}
return out
}
// loadTypeVariants parses types.txt: "<Base> = variant, variant, ..." and
// returns variant (uppercased) -> base as written in the file.
func loadTypeVariants(name string) map[string]string {
data, err := dictFS.ReadFile("resources/dicts/" + name + ".txt")
if err != nil {
panic("release parser: cannot load dictionary " + name + ": " + err.Error())
}
out := make(map[string]string)
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
base, rest, ok := strings.Cut(line, "=")
if !ok {
continue
}
base = strings.TrimSpace(base)
for v := range strings.SplitSeq(rest, ",") {
v = strings.ToUpper(strings.TrimSpace(v))
if v != "" {
out[v] = base
}
}
}
return out
}
// Profile holds the shared dictionaries for the release parser. The parser is
// group-agnostic: the group name is read from each release line, never from
// this profile, so there is a single set of dictionaries covering all groups.
var Profile = releaseparser.GroupProfile{
FormatTokens: loadDict("format"),
ArtistTokens: loadDict("artists"),
TypeVariants: loadTypeVariants("types"),
SourceTokens: loadDict("source"),
BitDepthTokens: loadDict("bitdepth"),
ResolutionTokens: loadDict("resolution"),
LanguageMarkers: loadDict("language"),
EditionTokens: loadDict("edition"),
Overrides: loadOverrides("overrides"),
CommentTokens: loadDict("comment"),
}
+25 -15
View File
@@ -1,32 +1,41 @@
package releaseparser package releaseparser
type Override struct {
Artist string
Title string
}
type GroupProfile struct { type GroupProfile struct {
Name string ArtistTokens map[string]bool // hyphenated artist whitelist
ExpectedTailOrder []string TypeVariants map[string]string // variant (upper) -> normalized Type
FormatTokens map[string]bool Overrides map[string]Override
SourceTokens map[string]bool FormatTokens map[string]bool
BitDepthTokens map[string]bool SourceTokens map[string]bool
ResolutionTokens map[string]bool BitDepthTokens map[string]bool
LanguageMarkers map[string]bool ResolutionTokens map[string]bool
EditionTokens map[string]bool LanguageMarkers map[string]bool
CommentTokens map[string]bool EditionTokens map[string]bool
CommentTokens map[string]bool
} }
type ParsedRelease struct { type ParsedRelease struct {
Raw string Raw string
Group string Group string
Artist string
Year int Year int
Title string
TitleExtra []string
Type string
Source string
Edition string
CatalogNo string
Language string
BitDepth string BitDepth string
Resolution string Resolution string
Comment string Comment string
CatalogNo string
Source string
Format string Format string
Language string
Edition string
Core string Core string
Artist string Override Override
Title string
UnknownTokens []string UnknownTokens []string
Warnings []string Warnings []string
Confidence float64 Confidence float64
@@ -40,6 +49,7 @@ type UnknownTokenStats struct {
type GroupStats struct { type GroupStats struct {
Total int Total int
TypeDistribution map[string]int
YearDistribution map[int]int YearDistribution map[int]int
SourceDistribution map[string]int SourceDistribution map[string]int
BitDepthDistribution map[string]int BitDepthDistribution map[string]int
-59
View File
@@ -1,59 +0,0 @@
scope prescene
FLOAT: 33.000000, 12.000000, 3
false
====================== 1 ==============================
{Id:825647 Type:prescene TypeId:4313240 Title:Phaethon-Wielder_Of_The_Steel-24BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/phaethon-wielder_of_the_steel-24bit-web-flac-2024-moonblood/ Created:2024-09-10 15:09:25 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 2 ==============================
{Id:825648 Type:prescene TypeId:4313238 Title:Helvetets_Port-Warlords-24BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/helvetets_port-warlords-24bit-web-flac-2024-moonblood/ Created:2024-09-10 15:08:33 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 4 ==============================
{Id:825649 Type:prescene TypeId:4313236 Title:Circle_of_Ouroborus-Ajattoman_Uni-FI-16BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/circle_of_ouroborus-ajattoman_uni-fi-16bit-web-flac-2024-moonblood/ Created:2024-09-10 15:07:20 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 5 ==============================
{Id:825650 Type:prescene TypeId:4313234 Title:Blitzkrieg-Blitzkrieg-24BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/blitzkrieg-blitzkrieg-24bit-web-flac-2024-moonblood/ Created:2024-09-10 15:06:36 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 6 ==============================
{Id:825651 Type:prescene TypeId:4313233 Title:Antioch-Onward_With_Obliteration-EP-16BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/antioch-onward_with_obliteration-ep-16bit-web-flac-2024-moonblood/ Created:2024-09-10 15:05:27 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 8 ==============================
{Id:825631 Type:prescene TypeId:4313221 Title:Wretchgod-Suffering_Upon_Suffering-EP-24BIT-WEB-FLAC-2024-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/wretchgod-suffering_upon_suffering-ep-24bit-web-flac-2024-moonblood/ Created:2024-09-10 14:07:36 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 9 ==============================
{Id:825632 Type:prescene TypeId:4313220 Title:Wretchgod-Stygian_Blood_Ritual-EP-16BIT-WEB-FLAC-2022-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/wretchgod-stygian_blood_ritual-ep-16bit-web-flac-2022-moonblood/ Created:2024-09-10 14:06:51 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 11 ==============================
{Id:825633 Type:prescene TypeId:4313188 Title:1349-The_Infernal_Pathway-REPACK-Deluxe_Edition-16BIT-WEB-FLAC-2019-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-the_infernal_pathway-repack-deluxe_edition-16bit-web-flac-2019-moonblood/ Created:2024-09-10 13:34:09 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 15 ==============================
{Id:825634 Type:prescene TypeId:4313177 Title:1349-The_Infernal_Pathway-Deluxe_Edition-16BIT-WEB-FLAC-2019-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-the_infernal_pathway-deluxe_edition-16bit-web-flac-2019-moonblood/ Created:2024-09-10 13:17:42 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 16 ==============================
{Id:825635 Type:prescene TypeId:4313175 Title:1349-Revelations_of_the_Black_Flame-16BIT-WEB-FLAC-2009-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-revelations_of_the_black_flame-16bit-web-flac-2009-moonblood/ Created:2024-09-10 13:16:03 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 17 ==============================
{Id:825636 Type:prescene TypeId:4313173 Title:1349-Massive_Cauldron_of_Chaos-16BIT-WEB-FLAC-2014-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-massive_cauldron_of_chaos-16bit-web-flac-2014-moonblood/ Created:2024-09-10 13:15:22 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 19 ==============================
{Id:825637 Type:prescene TypeId:4313171 Title:1349-Liberation-16BIT-WEB-FLAC-2003-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-liberation-16bit-web-flac-2003-moonblood/ Created:2024-09-10 13:14:38 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 21 ==============================
{Id:825638 Type:prescene TypeId:4313168 Title:1349-Hellfire-16BIT-WEB-FLAC-2005-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-hellfire-16bit-web-flac-2005-moonblood/ Created:2024-09-10 13:13:29 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 22 ==============================
{Id:825639 Type:prescene TypeId:4313166 Title:1349-Demonoir-REISSUE-16BIT-WEB-FLAC-2011-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-demonoir-reissue-16bit-web-flac-2011-moonblood/ Created:2024-09-10 13:12:45 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 23 ==============================
{Id:825640 Type:prescene TypeId:4313164 Title:1349-Beyond_the_Apocalypse-16BIT-WEB-FLAC-2004-MOONBLOOD TypeSubsectionId:0 Releaser:MOONBLOOD ExSource:https://scnlog.me/music/1349-beyond_the_apocalypse-16bit-web-flac-2004-moonblood/ Created:2024-09-10 13:12:00 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
Sleeping... 0
false
====================== 1 ==============================
{Id:825652 Type:prescene TypeId:4300530 Title:Zuckerbaby--Zuckerbaby-CD-FLAC-1997-MEOWZiK TypeSubsectionId:0 Releaser:MEOWZiK ExSource:https://scnlog.me/music/zuckerbaby-zuckerbaby-cd-flac-1997-meowzik/ Created:2024-08-31 18:17:03 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 2 ==============================
{Id:825653 Type:prescene TypeId:4300529 Title:Silmarils--Vegas_76-FR-CD-FLAC-2000-MEOWZiK TypeSubsectionId:0 Releaser:MEOWZiK ExSource:https://scnlog.me/music/silmarils-vegas_76-fr-cd-flac-2000-meowzik/ Created:2024-08-31 18:16:29 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 3 ==============================
{Id:825654 Type:prescene TypeId:4300527 Title:Michael_Kaeshammer--Days_Like_These-CD-FLAC-2007-MEOWZiK TypeSubsectionId:0 Releaser:MEOWZiK ExSource:https://scnlog.me/music/michael_kaeshammer-days_like_these-cd-flac-2007-meowzik/ Created:2024-08-31 18:14:25 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 4 ==============================
{Id:825655 Type:prescene TypeId:4300521 Title:Butch_Walker--Letters-CD-FLAC-2004-MEOWZiK TypeSubsectionId:0 Releaser:MEOWZiK ExSource:https://scnlog.me/music/butch_walker-letters-cd-flac-2004-meowzik/ Created:2024-08-31 18:09:22 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 16 ==============================
{Id:825656 Type:prescene TypeId:4300320 Title:Embodiment_Elimination-Metamorphosis_Incarnate_Through_Genetic_Devastation-(ISR071-24)-CD-FLAC-2024-86D TypeSubsectionId:0 Releaser:86D ExSource:https://scnlog.me/music/embodiment_elimination-metamorphosis_incarnate_through_genetic_devastation-isr071-24-cd-flac-2024-86d/ Created:2024-08-31 11:19:45 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 19 ==============================
{Id:825657 Type:prescene TypeId:4300309 Title:Putrified_Degradation-Manufractured-(ISR072-24)-CD-FLAC-2024-86D TypeSubsectionId:0 Releaser:86D ExSource:https://scnlog.me/music/putrified_degradation-manufractured-isr072-24-cd-flac-2024-86d/ Created:2024-08-31 10:45:04 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 20 ==============================
{Id:825658 Type:prescene TypeId:4300307 Title:Post_Mortal_Possession-The_Dead_Space_Between_the_Stars-(LORD091)-CD-FLAC-2024-86D TypeSubsectionId:0 Releaser:86D ExSource:https://scnlog.me/music/post_mortal_possession-the_dead_space_between_the_stars-lord091-cd-flac-2024-86d/ Created:2024-08-31 10:43:09 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 21 ==============================
{Id:825659 Type:prescene TypeId:4300306 Title:Invirulant-Necrothuggin-(ISR070-24)-CDEP-FLAC-2024-86D TypeSubsectionId:0 Releaser:86D ExSource:https://scnlog.me/music/invirulant-necrothuggin-isr070-24-cdep-flac-2024-86d/ Created:2024-08-31 10:41:04 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
====================== 22 ==============================
{Id:825660 Type:prescene TypeId:4300304 Title:Dead_and_Dripping-Disillusioned_by_Excessive_Human_Consumption-(LORD092)-DEMO_REISSUE-CD-FLAC-2024-86D TypeSubsectionId:0 Releaser:86D ExSource:https://scnlog.me/music/dead_and_dripping-disillusioned_by_excessive_human_consumption-lord092-demo_reissue-cd-flac-2024-86d/ Created:2024-08-31 10:38:13 Modified: Contents: A: H: Fingerprint: FsFingerprint: Vid:0 G: ExternalSourcesCol: Error:<nil>}
Sleeping... 1
scope: prescene
[1 45]
init console console: ps
Execution took 12.6276275s
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func (h *Handler) Console() string { func (h *Handler) Console() (string, error) {
viper.SetDefault("env", "devel") viper.SetDefault("env", "devel")
cmd := viper.GetString("create") cmd := viper.GetString("create")
cmdList := viper.Sub("console.cmd") cmdList := viper.Sub("console.cmd")
@@ -16,11 +16,11 @@ func (h *Handler) Console() string {
if !slices.Contains(allowCreate, cmd) { if !slices.Contains(allowCreate, cmd) {
fmt.Printf("%s Not allowed command %s used\n", aurora.BgMagenta("[WARN]"), aurora.Magenta(cmd)) fmt.Printf("%s Not allowed command %s used\n", aurora.BgMagenta("[WARN]"), aurora.Magenta(cmd))
return "" return "", fmt.Errorf("command %q is not allowed", cmd)
} }
fmt.Printf("%v\n", slices.Contains(allowCreate, cmd)) fmt.Printf("%v\n", slices.Contains(allowCreate, cmd))
return viper.GetString("env") return viper.GetString("env"), nil
} }
+18 -10
View File
@@ -1,7 +1,7 @@
package handler package handler
import ( import (
"log" // Додано імпорт log "fmt"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -10,7 +10,9 @@ import (
) )
// consoleCommandHandler визначає тип для функцій-обробників консольних команд. // consoleCommandHandler визначає тип для функцій-обробників консольних команд.
type consoleCommandHandler func() string // The error it returns decides the process exit code, so cron can tell a failed
// run from a successful one.
type consoleCommandHandler func() (string, error)
type Handler struct { type Handler struct {
services *service.Service services *service.Service
@@ -29,24 +31,30 @@ func New(services *service.Service) *Handler {
"prescene": h.Prescene, "prescene": h.Prescene,
"rutracker": h.Rutracker, "rutracker": h.Rutracker,
"stb": h.STB, "stb": h.STB,
"srrdb": h.Srrdb,
//"web": h.Web, //"web": h.Web,
} }
return h return h
} }
func (h *Handler) InitConsole() string { // InitConsole runs the command selected by --scope-enable. The text is returned
// even together with an error, so the caller can print what happened before it
// reports the failure.
func (h *Handler) InitConsole() (string, error) {
commandName := viper.GetString(constant.FlagScopeEnable) commandName := viper.GetString(constant.FlagScopeEnable)
if cmdFunc, ok := h.consoleCommands[commandName]; ok { cmdFunc, ok := h.consoleCommands[commandName]
return commandName + " launched, " + cmdFunc() + "\n" if !ok {
} else { return "", fmt.Errorf("unknown console command %q", commandName)
log.Printf("Error: unknown console command '%s'", commandName)
return "Error: unknown console command"
} }
result, err := cmdFunc()
return commandName + " launched, " + result + "\n", err
} }
func (h *Handler) InitRoutes() string { func (h *Handler) InitRoutes() (string, error) {
//TODO: //TODO:
return "i am the web initiator" return "i am the web initiator", nil
} }
+3 -2
View File
@@ -12,13 +12,14 @@ import (
"git.kplus.net.ua/yevhen/resource-scraper/types/constant" "git.kplus.net.ua/yevhen/resource-scraper/types/constant"
) )
func (h *Handler) Info() string { func (h *Handler) Info() (string, error) {
md5() md5()
fmt.Printf("%s: %s; %s: %s\n", fmt.Printf("%s: %s; %s: %s\n",
aurora.Cyan("ENV"), aurora.Cyan("ENV"),
viper.GetString(constant.FlagEnv), aurora.Cyan("SCOPE"), viper.GetString(constant.FlagScopeEnable)) viper.GetString(constant.FlagEnv), aurora.Cyan("SCOPE"), viper.GetString(constant.FlagScopeEnable))
return "info"
return "info", nil
} }
func md5() { func md5() {
+2 -2
View File
@@ -1,5 +1,5 @@
package handler package handler
func (h *Handler) MetalArchives() string { func (h *Handler) MetalArchives() (string, error) {
return "MetalArchives................." return "MetalArchives.................", nil
} }
+6 -9
View File
@@ -2,7 +2,6 @@ package handler
import ( import (
"fmt" "fmt"
"log/slog"
"math" "math"
"strconv" "strconv"
"time" "time"
@@ -12,7 +11,7 @@ import (
"git.kplus.net.ua/yevhen/resource-scraper/types/constant" "git.kplus.net.ua/yevhen/resource-scraper/types/constant"
) )
func (h *Handler) Prescene() string { func (h *Handler) Prescene() (string, error) {
currMinute := time.Now().Minute() currMinute := time.Now().Minute()
levels := viper.GetInt(constant.CfgKeyLevelsToScrape) levels := viper.GetInt(constant.CfgKeyLevelsToScrape)
initPage := strconv.Itoa(currMinute/(60/levels) + 1) initPage := strconv.Itoa(currMinute/(60/levels) + 1)
@@ -30,17 +29,15 @@ func (h *Handler) Prescene() string {
pagesToScrape = append(pagesToScrape, strconv.Itoa(page)) pagesToScrape = append(pagesToScrape, strconv.Itoa(page))
} }
_, err := h.services.Prescene.GetSrrDb() if _, err := h.services.Prescene.GetSrrDb(); err != nil {
if err != nil { return "ps", fmt.Errorf("getting srr db: %w", err)
slog.Error("error occurred while getting page: ", "err", err.Error())
} }
_, err = h.services.Prescene.GetPage(pagesToScrape) if _, err := h.services.Prescene.GetPage(pagesToScrape); err != nil {
if err != nil { return "ps", fmt.Errorf("getting page: %w", err)
slog.Error("error occurred while getting page: ", "err", err.Error())
} }
fmt.Printf("%+v\n", pagesToScrape) fmt.Printf("%+v\n", pagesToScrape)
return "ps" return "ps", nil
} }
+4 -6
View File
@@ -2,20 +2,18 @@ package handler
import ( import (
"fmt" "fmt"
"log/slog"
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func (h *Handler) Rutracker() string { func (h *Handler) Rutracker() (string, error) {
key := fmt.Sprintf("topic.%v", time.Now().Hour()) key := fmt.Sprintf("topic.%v", time.Now().Hour())
topics := viper.GetStringSlice(key) topics := viper.GetStringSlice(key)
_, err := h.services.Rutracker.GetTopic(topics) if _, err := h.services.Rutracker.GetTopic(topics); err != nil {
if err != nil { return "rt", fmt.Errorf("getting topic: %w", err)
slog.Error("error occurred while getting topic: ", "err", err.Error())
} }
return "rt" return "rt", nil
} }
+22
View File
@@ -0,0 +1,22 @@
package handler
import (
"errors"
"fmt"
"github.com/spf13/viper"
"git.kplus.net.ua/yevhen/resource-scraper/types/constant"
)
// Srrdb runs one srrDB search pass over the raw path given by --single-uri.
func (h *Handler) Srrdb() (string, error) {
uri := viper.GetString(constant.FlagSingleUri)
if uri == "" {
return "srrdb", errors.New("no search path, pass --single-uri")
}
stored, err := h.services.Srrdb.Search(uri)
return fmt.Sprintf("%d records\n", stored), err
}
+3 -5
View File
@@ -3,20 +3,18 @@ package handler
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"github.com/spf13/viper" "github.com/spf13/viper"
"git.kplus.net.ua/yevhen/resource-scraper/types/constant" "git.kplus.net.ua/yevhen/resource-scraper/types/constant"
) )
func (h *Handler) STB() string { func (h *Handler) STB() (string, error) {
endpoint := fmt.Sprintf("%s.%s", constant.ScopeShareTheBrutality, constant.CfgKeyEndpoint) endpoint := fmt.Sprintf("%s.%s", constant.ScopeShareTheBrutality, constant.CfgKeyEndpoint)
endpoint = viper.GetString(endpoint) endpoint = viper.GetString(endpoint)
if endpoint == "" { if endpoint == "" {
slog.Error("getting endpoint from config", "err", errors.New("no endpoint provided")) return "stb", errors.New("no endpoint provided")
return "stb"
} }
es, ms := h.services.ShareTheBrutality.GetMail(endpoint) es, ms := h.services.ShareTheBrutality.GetMail(endpoint)
@@ -32,5 +30,5 @@ func (h *Handler) STB() string {
slog.Error("error occurred while getting topic: ", "err", err) slog.Error("error occurred while getting topic: ", "err", err)
}*/ }*/
return fmt.Sprintf("Added %d records\n", len(es)) return fmt.Sprintf("Added %d records\n", len(es)), nil
} }
+235 -10
View File
@@ -1,9 +1,15 @@
package repository package repository
import ( import (
"database/sql"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os"
"path/filepath"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -46,7 +52,14 @@ func (s *Prescene) GetPage(pageNumbers []string) ([]model.ExternalSources, error
endpoint := viper.GetString(constant.CfgKeyEndpoint) endpoint := viper.GetString(constant.CfgKeyEndpoint)
uri := viper.GetString(constant.FlagSingleUri) uri := viper.GetString(constant.FlagSingleUri)
if uri != "" {
if uri == "page::1m" {
if result, err := parseSingleScnlog(s.db); err != nil {
slog.Error("parsing single scnlog page", "err", err)
} else {
entries = append(entries, result...)
}
} else if uri != "" {
url := fmt.Sprintf("%s/%s", strings.Trim(endpoint, "/"), strings.Trim(uri, "/")) url := fmt.Sprintf("%s/%s", strings.Trim(endpoint, "/"), strings.Trim(uri, "/"))
result, _ := parseUrl(url, s.db) result, _ := parseUrl(url, s.db)
entries = append(entries, result...) entries = append(entries, result...)
@@ -85,16 +98,25 @@ func parseUrl(endpoint string, db *sqlx.DB) ([]model.ExternalSources, error) {
} }
var validID = regexp.MustCompile(`-\d+\/$`) var validID = regexp.MustCompile(`-\d+\/$`)
for i, item := range dom.QuerySelectorAll(doc, ".post.type-post.category-flac.category-music") { for i, item := range dom.QuerySelectorAll(doc, "li.row") {
var es model.ExternalSources var es model.ExternalSources
columns := []string{"`type`", "type_id", "title", "eXsource", "releaser", "created"} columns := []string{"`type`", "type_id", "title", "eXsource", "releaser", "created"}
// TypeId тепер напряму з data-id на <li>, без regex по class
idAttr := dom.GetAttribute(item, "data-id")
es.TypeId, _ = strconv.Atoi(idAttr)
title := dom.QuerySelector(item, ".title") title := dom.QuerySelector(item, ".title")
if title != nil { if title != nil {
anchor := dom.QuerySelector(title, "h1 > a") anchor := dom.QuerySelector(title, "a")
if anchor != nil { if anchor != nil {
es.Type = constant.ScopePrescene es.Type = constant.ScopePrescene
es.Title = dom.GetAttribute(anchor, "title")
titleSpan := dom.QuerySelector(anchor, "span.title-start")
if titleSpan != nil {
es.Title = dom.TextContent(titleSpan)
}
if es.Title == "Auto Draft" { if es.Title == "Auto Draft" {
slog.Info("Skipped", "title", es.Title) slog.Info("Skipped", "title", es.Title)
continue continue
@@ -123,24 +145,227 @@ func parseUrl(endpoint string, db *sqlx.DB) ([]model.ExternalSources, error) {
continue continue
} }
localtime := dom.QuerySelector(title, "small > span.localtime") localtime := dom.QuerySelector(item, "span.localtime")
if localtime != nil { if localtime != nil {
lc := dom.GetAttribute(localtime, "data-lttime") lc := dom.GetAttribute(localtime, "data-lttime")
es.Created = carbon.Parse(lc, "Europe/Kyiv") es.Created = carbon.Parse(lc, "Europe/Kyiv")
} }
} }
cls := dom.GetAttribute(item, "class")
pattern := regexp.MustCompile(`(?s)^post-(\d+)\spost`)
es.TypeId, _ = strconv.Atoi(pattern.FindStringSubmatch(cls)[1])
esModel := table.ExternalSources{Columns: columns} esModel := table.ExternalSources{Columns: columns}
entry := esModel.InsertOnDuplicate(es, db) entry := esModel.InsertOnDuplicate(es, db)
entries = append(entries, entry) entries = append(entries, entry)
fmt.Println("====================== ", i, " ==============================") fmt.Println("====================== ", i, " ==============================")
fmt.Printf("%+v\n", entry) //fmt.Printf("%+v\n", es)
fmt.Printf("%s %d %d %s\n", entry.Created.ToString(), entry.Id, entry.TypeId, entry.Title)
//spew.Dump(entry)
} }
return entries, nil return entries, nil
} }
func parseSingleScnlog(db *sqlx.DB) ([]model.ExternalSources, error) {
entries := make([]model.ExternalSources, 0)
const query = "SELECT id, type_id, title, meta, eXsource " +
"FROM theamok_mdb.external_sources " +
"WHERE type = 'prescene' AND created >= DATE_SUB(NOW(), INTERVAL 48 HOUR) AND meta IS NULL " +
"ORDER BY created ASC LIMIT 1"
var src model.ScnlogSource
if err := db.Get(&src, query); err != nil {
if errors.Is(err, sql.ErrNoRows) {
slog.Info("no prescene rows with meta IS NULL - nothing to do")
return entries, nil
}
return nil, err
}
doc, err := parser.HTMLSourceFromURL(src.ExSource)
if err != nil {
return nil, err
}
if doc == nil {
return nil, errors.New("document is nil")
}
article := dom.QuerySelector(doc, "article.single")
if article == nil {
return nil, fmt.Errorf("article.single not found: %s", src.ExSource)
}
// type_id з class="single post-5227778 ..."
typeId := src.TypeId
if m := regexp.MustCompile(`post-(\d+)`).FindStringSubmatch(dom.GetAttribute(article, "class")); len(m) > 1 {
if v, err := strconv.ParseInt(m[1], 10, 64); err == nil {
typeId = v
}
}
content := dom.QuerySelector(article, ".entry-content")
contentText := ""
if content != nil {
contentText = dom.TextContent(content)
}
meta := model.ScnlogMeta{}
// size_mb — тільки число з "Size: 217.26 MB"
if m := regexp.MustCompile(`Size:\s*([\d.]+)\s*MB`).FindStringSubmatch(contentText); len(m) > 1 {
if v, err := strconv.ParseFloat(m[1], 64); err == nil {
meta.SizeMb = &v
}
}
// genre — один рядок
if m := regexp.MustCompile(`Genre:\s*([^\n]+)`).FindStringSubmatch(contentText); len(m) > 1 {
if g := strings.TrimSpace(m[1]); g != "" {
meta.Genre = &g
}
}
// artist / tracks / duration з .mp3tbl
if tbl := dom.QuerySelector(article, ".mp3tbl"); tbl != nil {
all := make([]string, 0)
for _, td := range dom.QuerySelectorAll(tbl, "td.mp3tbl-artist") {
all = append(all, strings.TrimSpace(dom.TextContent(td)))
}
if unique := uniqueStrings(all); len(unique) > 0 {
meta.Artist = &unique
}
tracks := len(dom.QuerySelectorAll(tbl, "tbody tr"))
meta.Tracks = &tracks
total := 0
for _, td := range dom.QuerySelectorAll(tbl, "td.mp3tbl-time") {
total += durationToSeconds(strings.TrimSpace(dom.TextContent(td)))
}
duration := secondsToHMS(total)
meta.Duration = &duration
}
// NFO: анкор із текстом "Download NFO", шлях з onclick
if content == nil {
slog.Warn("entry-content not found", "url", src.ExSource)
} else {
for _, a := range dom.QuerySelectorAll(content, "a") {
if !strings.Contains(dom.TextContent(a), "Download NFO") {
continue
}
onclick := dom.GetAttribute(a, "onclick")
spew.Dump("NFO-ANCHOR", dom.TextContent(a), onclick)
if m := regexp.MustCompile(`'([^']+)'`).FindStringSubmatch(dom.GetAttribute(a, "onclick")); len(m) > 1 {
nfoURL := "https://scnlog.me" + m[1]
if err := saveNfo(src.Title, nfoURL, src.ExSource); err != nil {
slog.Warn("nfo not saved", "title", src.Title, "err", err)
}
}
break
}
}
payload, err := json.Marshal(meta) // compact = "uglify"
if err != nil {
return nil, err
}
// якщо всі поля порожні — пишемо порожній об'єкт, а не набір null
if meta.SizeMb == nil && meta.Genre == nil && meta.Artist == nil && meta.Tracks == nil && meta.Duration == nil {
payload = []byte("{}")
}
if _, err := db.Exec(
"UPDATE `theamok_mdb`.`external_sources` SET `meta` = ? WHERE `id` = ?",
string(payload), src.Id,
); err != nil {
return nil, err
}
slog.Info("scnlog meta updated",
"id", src.Id, "type_id", typeId, "title", src.Title, "meta", string(payload))
return entries, nil
}
// uniqueStrings прибирає дублікати, зберігаючи порядок першої появи.
func uniqueStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// durationToSeconds парсить "m:ss" / "h:mm:ss" у секунди.
func durationToSeconds(v string) int {
parts := strings.Split(strings.TrimSpace(v), ":")
if len(parts) == 0 {
return 0
}
total := 0
for _, p := range parts {
n, _ := strconv.Atoi(strings.TrimSpace(p))
total = total*60 + n
}
return total
}
// secondsToHMS форматує секунди у "h:M:s" (напр. 1802 -> "0:30:02").
func secondsToHMS(total int) string {
return fmt.Sprintf("%d:%02d:%02d", total/3600, (total%3600)/60, total%60)
}
// saveNfo пише NFO у <nfo_dir>/<перша літера title в lower>/<title>.nfo
func saveNfo(title, nfoURL, referer string) error {
dir := viper.GetString("nfo_dir")
if dir == "" {
return errors.New("nfo_dir is empty")
}
letter := strings.ToLower(string([]rune(title)[0]))
sub := filepath.Join(dir, letter)
if err := os.MkdirAll(sub, 0o755); err != nil {
return err
}
data, err := fetchNfoBytes(nfoURL, referer)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(sub, title+".nfo"), data, 0o644)
}
// fetchNfoBytes завантажує сирі байти NFO (не HTML) у []byte.
func fetchNfoBytes(nfoURL, referer string) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, nfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
if referer != "" {
req.Header.Set("Referer", referer)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, nfoURL)
}
return io.ReadAll(resp.Body)
}
+5 -1
View File
@@ -10,12 +10,16 @@ type Repository struct {
_interface.Rutracker _interface.Rutracker
_interface.Prescene _interface.Prescene
_interface.ShareTheBrutality _interface.ShareTheBrutality
_interface.Srrdb
} }
func New(db *sqlx.DB) *Repository { // New wires the collectors. sqlite serves the srrdb scope only and may be nil
// for every other source; it is connected lazily in Bootstrap.
func New(db, sqlite *sqlx.DB) *Repository {
return &Repository{ return &Repository{
Rutracker: NewRutrackerRepository(db), Rutracker: NewRutrackerRepository(db),
Prescene: NewPresceneRepository(db), Prescene: NewPresceneRepository(db),
ShareTheBrutality: NewShareTheBrutalityRepository(db), ShareTheBrutality: NewShareTheBrutalityRepository(db),
Srrdb: NewSrrdbRepository(sqlite),
} }
} }
-2
View File
@@ -51,8 +51,6 @@ func (s *Rutracker) GetTopic(topics []string) ([]model.ExternalSources, error) {
es.Created = carbon.Parse(e.Updated, "Europe/Kyiv") es.Created = carbon.Parse(e.Updated, "Europe/Kyiv")
esModel := table.ExternalSources{Columns: columns} esModel := table.ExternalSources{Columns: columns}
fmt.Printf("%+v\n\n\n", es)
entry := esModel.InsertOnDuplicate(es, s.db) entry := esModel.InsertOnDuplicate(es, s.db)
entries = append(entries, entry) entries = append(entries, entry)
} }
+185
View File
@@ -0,0 +1,185 @@
package repository
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/jmoiron/sqlx"
"git.kplus.net.ua/yevhen/resource-scraper/pkg/repository/table"
"git.kplus.net.ua/yevhen/resource-scraper/types/model"
)
// srrdbTimestamp keeps created_at in the same "YYYY-MM-DD HH:MM:SS" layout the
// API itself uses in its date field, because the column is TEXT.
const srrdbTimestamp = "2006-01-02 15:04:05"
// Srrdb harvests the srrDB search API into the separate SQLite database.
type Srrdb struct {
db *sqlx.DB
}
// NewSrrdbRepository returns a collector writing into the SQLite handle. The
// handle is nil for every scope except srrdb, because Bootstrap connects the
// SQLite database lazily, so the collector refuses to run without it instead of
// failing later on a nil handle.
func NewSrrdbRepository(db *sqlx.DB) *Srrdb {
return &Srrdb{db: db}
}
// Search harvests one search path. Nothing is written to the database until the
// whole set is collected and its size matches what the API reported, so the
// dump and the success log can only ever describe a complete day.
func (s *Srrdb) Search(rawURI string) (int, error) {
if s.db == nil {
err := errors.New("srrdb collector has no database handle")
srrdbLogError(rawURI, err)
return 0, err
}
if !strings.Contains(rawURI, "category:") {
err := fmt.Errorf("search path %q has no category: segment", rawURI)
srrdbLogError(rawURI, err)
return 0, err
}
rules, err := srrdbLoadGroupRules()
if err != nil {
srrdbLogError(rawURI, err)
return 0, err
}
auto := strings.Contains(rawURI, srrdbAutoSegment)
now := time.Now()
uri, err := srrdbResolveAutoDate(srrdbResolveRelativeDays(rawURI, now), now)
switch {
case errors.Is(err, errSrrdbFloor):
slog.Info("srrdb backfill complete, srrdb.floor reached", "uri", rawURI)
return 0, nil
case err != nil:
srrdbLogError(rawURI, err)
return 0, err
}
// A slash typed on either end of the command line would build a double
// separator in the URL and an odd dump file name.
uri = strings.Trim(uri, "/")
// The guard applies to the automatic walk only. A day named on the command
// line is fetched again and its dump rewritten every time: a day can still
// gain releases later, either from another time zone or from a late upload,
// and cron refreshes today and yesterday on purpose.
dump := srrdbDumpPath(uri)
if auto {
exists, err := srrdbExists(dump)
if err != nil {
srrdbLogError(uri, err)
return 0, err
}
if exists {
slog.Info("srrdb already harvested, skipping", "uri", uri, "dump", dump)
return 0, nil
}
}
collected, err := srrdbCollectAll(uri)
if err != nil {
srrdbLogError(uri, err)
return 0, err
}
// An empty day is a normal outcome: zero results and no warnings. The dump
// records it as harvested, which keeps the walk moving. Warnings mean the
// path was not understood, and that must never be remembered as done.
switch {
case len(collected.Warnings) > 0:
srrdbLogWarning(uri, collected.Warnings)
return 0, fmt.Errorf("srrdb %q reported warnings: %s",
uri, strings.Join(collected.Warnings, "; "))
case len(collected.Results) != collected.ResultsCount:
err := fmt.Errorf("srrdb %q pulled %d of %d reported releases",
uri, len(collected.Results), collected.ResultsCount)
srrdbLogError(uri, err)
return 0, err
}
run := time.Now().Format(srrdbTimestamp)
category := srrdbCategory(uri)
rows := make([]model.SrrdbRelease, 0, len(collected.Results))
day := &model.SrrdbLog{
Category: category,
RunAt: run,
}
day.Found = len(collected.Results)
for _, result := range collected.Results {
row := srrdbRow(result, run, category)
if rules.ignore[row.GroupName] {
slog.Info("srrdb release ignored by group rule", "release", row.Release, "group", row.GroupName)
day.Ignored++
continue
}
if status, exists := rules.status[row.GroupName]; exists {
row.Status = &status
}
rows = append(rows, row)
}
day.Stored = len(rows)
_, dateRest, ok := strings.Cut(uri, "date:")
if !ok {
err := fmt.Errorf("search path %q has no date: segment", uri)
srrdbLogError(uri, err)
return 0, err
}
dayName, _, _ := strings.Cut(dateRest, "/")
if _, err := time.Parse(srrdbDayLayout, dayName); err != nil {
err := fmt.Errorf("search path %q has an invalid date %q", uri, dayName)
srrdbLogError(uri, err)
return 0, err
}
day.Day = dayName
if err := table.UpsertAllSrrdbWithDay(s.db, rows, day); err != nil {
srrdbLogError(uri, err)
return 0, err
}
if err := srrdbWriteDump(dump, collected); err != nil {
srrdbLogError(uri, err)
return len(rows), err
}
srrdbLogStatus(uri, len(rows), collected.ResultsCount)
return len(rows), nil
}
+155
View File
@@ -0,0 +1,155 @@
package repository
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
"git.kplus.net.ua/yevhen/resource-scraper/types/constant"
"git.kplus.net.ua/yevhen/resource-scraper/types/resource"
)
// srrdbPageSegment locates the page:N segment so a run can continue from N into
// the following pages.
var srrdbPageSegment = regexp.MustCompile(`page:(\d+)`)
// srrdbCollectAll walks every page before anything is written, so the caller
// only ever receives a complete set or an error.
func srrdbCollectAll(uri string) (*resource.SrrdbSearchResponse, error) {
endpoint := strings.TrimRight(viper.GetString("srrdb.endpoint"), "/")
// The flag binds to its own key and wins over the config value; an unused
// flag yields zero.
sleep := viper.GetDuration(constant.FlagSleep)
if sleep <= 0 {
sleep = viper.GetDuration("srrdb.sleep")
}
if sleep <= 0 {
sleep = 2 * time.Second
}
slog.Info("srrdb pacing", "uri", uri, "sleep", sleep)
page, paginated := srrdbStartPage(uri)
collected := &resource.SrrdbSearchResponse{}
for {
url := endpoint + "/" + srrdbWithPage(uri, page, paginated)
response, err := srrdbFetch(url)
if err != nil {
return nil, err
}
srrdbCollect(collected, response)
slog.Info("srrdb page fetched",
"url", url,
"page", page,
"fetched", len(response.Results),
"reported", response.ResultsCount)
if !paginated || len(response.Results) == 0 {
break
}
// Everything the API reported is already collected: one more page would
// cost a request and a pause to receive an empty answer.
if collected.ResultsCount > 0 && len(collected.Results) >= collected.ResultsCount {
break
}
page++
time.Sleep(sleep)
}
return collected, nil
}
// srrdbCollect merges one page into the answer kept for the dump: results are
// appended, while resultsCount and query come from the first page.
func srrdbCollect(all, page *resource.SrrdbSearchResponse) {
if all.ResultsCount == 0 {
all.ResultsCount = page.ResultsCount
}
if len(all.Query) == 0 {
all.Query = page.Query
}
all.Results = append(all.Results, page.Results...)
all.Warnings = append(all.Warnings, page.Warnings...)
}
// srrdbStartPage reads the page:N segment. Without it the path is a single
// request and no further pages are attempted.
func srrdbStartPage(uri string) (int, bool) {
match := srrdbPageSegment.FindStringSubmatch(uri)
if match == nil {
return 1, false
}
page, err := strconv.Atoi(match[1])
if err != nil || page < 1 {
return 1, true
}
return page, true
}
// srrdbWithPage swaps the page:N segment for the page requested now.
func srrdbWithPage(uri string, page int, paginated bool) string {
if !paginated {
return uri
}
return srrdbPageSegment.ReplaceAllString(uri, "page:"+strconv.Itoa(page))
}
// srrdbFetch performs one search request and decodes the JSON answer.
func srrdbFetch(url string) (*resource.SrrdbSearchResponse, error) {
client := &http.Client{Timeout: 30 * time.Second}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build srrdb request %q: %w", url, err)
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("request srrdb %q: %w", url, err)
}
defer func() {
if cerr := response.Body.Close(); cerr != nil {
slog.Error("closing srrdb response body", "url", url, "err", cerr)
}
}()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("srrdb %q replied %s", url, response.Status)
}
body, err := io.ReadAll(io.LimitReader(response.Body, 16<<20))
if err != nil {
return nil, fmt.Errorf("read srrdb body %q: %w", url, err)
}
decoded := &resource.SrrdbSearchResponse{}
if err := json.Unmarshal(body, decoded); err != nil {
return nil, fmt.Errorf("decode srrdb body %q: %w", url, err)
}
return decoded, nil
}
+189
View File
@@ -0,0 +1,189 @@
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
}
+256
View File
@@ -0,0 +1,256 @@
package repository
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/viper"
)
// srrdbTestNow pins the moment the date logic is tested against, so no assertion
// depends on the wall clock.
var srrdbTestNow = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
// srrdbTestDir gives every test a clean viper and an empty dump directory, because
// the package reads srrdb.dump_dir from the global configuration instance.
func srrdbTestDir(t *testing.T) string {
t.Helper()
viper.Reset()
dir := t.TempDir()
viper.Set("srrdb.dump_dir", dir)
return dir
}
// srrdbTestFloor points srrdb.floor at the given day.
func srrdbTestFloor(t *testing.T, day time.Time) {
t.Helper()
viper.Set("srrdb.floor", day.Format(srrdbDayLayout))
}
// srrdbTestDump writes a dump file for a query, which is how the walk learns that
// a day is done.
func srrdbTestDump(t *testing.T, uri string) {
t.Helper()
path := srrdbDumpPath(uri)
if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil {
t.Fatalf("write dump %q: %v", path, err)
}
}
func TestSrrdbFloorDateFromString(t *testing.T) {
srrdbTestDir(t)
viper.Set("srrdb.floor", "2013-03-16")
floor, err := srrdbFloorDate()
if err != nil {
t.Fatalf("srrdbFloorDate: %v", err)
}
want := time.Date(2013, 3, 16, 0, 0, 0, 0, time.UTC)
if !floor.Equal(want) {
t.Errorf("srrdbFloorDate = %s, want %s", floor, want)
}
}
// The regression this test exists for: YAML decodes an unquoted date as a
// timestamp, so the value reaches the code as a time.Time and not as a string. It
// used to fail with "extra text: 00:00:00 +0000 UTC".
func TestSrrdbFloorDateFromTimestamp(t *testing.T) {
srrdbTestDir(t)
want := time.Date(2013, 3, 16, 0, 0, 0, 0, time.UTC)
viper.Set("srrdb.floor", want)
floor, err := srrdbFloorDate()
if err != nil {
t.Fatalf("srrdbFloorDate: %v", err)
}
if !floor.Equal(want) {
t.Errorf("srrdbFloorDate = %s, want %s", floor, want)
}
}
func TestSrrdbFloorDateNotConfigured(t *testing.T) {
srrdbTestDir(t)
if _, err := srrdbFloorDate(); err == nil {
t.Error("srrdbFloorDate without srrdb.floor returned no error")
}
}
func TestSrrdbDumpPattern(t *testing.T) {
pattern, err := srrdbDumpPattern("category:flac/date:auto/page:1")
if err != nil {
t.Fatalf("srrdbDumpPattern: %v", err)
}
match := pattern.FindStringSubmatch(srrdbNormalizedPath("category:flac/date:2026-09-28/page:1"))
if match == nil {
t.Fatal("the pattern did not match the dump name of the same query")
}
if match[1] != "2026-09-28" {
t.Errorf("captured day = %q, want 2026-09-28", match[1])
}
}
func TestSrrdbDumpPatternNeedsAutoSegment(t *testing.T) {
if _, err := srrdbDumpPattern("category:flac/date:2026-09-28/page:1"); err == nil {
t.Error("srrdbDumpPattern without date:auto returned no error")
}
}
func TestSrrdbDumpedDaysIgnoresForeignQueries(t *testing.T) {
srrdbTestDir(t)
srrdbTestDump(t, "category:flac/date:2026-09-28/page:1")
srrdbTestDump(t, "category:flac/date:2026-09-27/page:1")
srrdbTestDump(t, "category:mp3/date:2026-09-26/page:1")
days, err := srrdbDumpedDays("category:flac/date:auto/page:1")
if err != nil {
t.Fatalf("srrdbDumpedDays: %v", err)
}
if len(days) != 2 {
t.Fatalf("len(days) = %d, want 2: only dumps of the same query may count", len(days))
}
}
// An absent directory must not read as "nothing harvested yet": the dumps are the
// cursor of the walk, so a renamed or unmounted directory would otherwise send the
// run silently back to the beginning.
func TestSrrdbDumpedDaysMissingDirectory(t *testing.T) {
viper.Reset()
viper.Set("srrdb.dump_dir", filepath.Join(t.TempDir(), "absent"))
if _, err := srrdbDumpedDays("category:flac/date:auto/page:1"); err == nil {
t.Error("srrdbDumpedDays on an absent directory returned no error")
}
}
func TestSrrdbResolveAutoDateStartsFromYesterday(t *testing.T) {
srrdbTestDir(t)
srrdbTestFloor(t, srrdbTestNow.AddDate(0, 0, -10))
got, err := srrdbResolveAutoDate("category:flac/date:auto/page:1", srrdbTestNow)
if err != nil {
t.Fatalf("srrdbResolveAutoDate: %v", err)
}
want := "category:flac/date:2026-09-29/page:1"
if got != want {
t.Errorf("srrdbResolveAutoDate = %q, want %q: the current day is still being filled", got, want)
}
}
func TestSrrdbResolveAutoDatePicksTheNewestMissingDay(t *testing.T) {
srrdbTestDir(t)
srrdbTestFloor(t, srrdbTestNow.AddDate(0, 0, -10))
srrdbTestDump(t, "category:flac/date:2026-09-29/page:1")
srrdbTestDump(t, "category:flac/date:2026-09-28/page:1")
got, err := srrdbResolveAutoDate("category:flac/date:auto/page:1", srrdbTestNow)
if err != nil {
t.Fatalf("srrdbResolveAutoDate: %v", err)
}
want := "category:flac/date:2026-09-27/page:1"
if got != want {
t.Errorf("srrdbResolveAutoDate = %q, want %q", got, want)
}
}
// A hole in the middle must be closed once the walk reaches it, not declared
// covered: this is the defect that made the previous cursor stop at the floor.
func TestSrrdbResolveAutoDateClosesHoles(t *testing.T) {
srrdbTestDir(t)
srrdbTestFloor(t, srrdbTestNow.AddDate(0, 0, -10))
srrdbTestDump(t, "category:flac/date:2026-09-29/page:1")
srrdbTestDump(t, "category:flac/date:2026-09-27/page:1")
got, err := srrdbResolveAutoDate("category:flac/date:auto/page:1", srrdbTestNow)
if err != nil {
t.Fatalf("srrdbResolveAutoDate: %v", err)
}
want := "category:flac/date:2026-09-28/page:1"
if got != want {
t.Errorf("srrdbResolveAutoDate = %q, want %q: the gap above the floor must be filled first", got, want)
}
}
func TestSrrdbResolveAutoDateStopsAtFloor(t *testing.T) {
srrdbTestDir(t)
srrdbTestFloor(t, srrdbTestNow.AddDate(0, 0, -1))
srrdbTestDump(t, "category:flac/date:2026-09-29/page:1")
_, err := srrdbResolveAutoDate("category:flac/date:auto/page:1", srrdbTestNow)
if !errors.Is(err, errSrrdbFloor) {
t.Errorf("srrdbResolveAutoDate = %v, want errSrrdbFloor", err)
}
}
func TestSrrdbResolveAutoDateIgnoresConcreteDates(t *testing.T) {
srrdbTestDir(t)
uri := "category:flac/date:2026-09-28/page:1"
got, err := srrdbResolveAutoDate(uri, srrdbTestNow)
if err != nil {
t.Fatalf("srrdbResolveAutoDate: %v", err)
}
if got != uri {
t.Errorf("srrdbResolveAutoDate = %q, want %q left untouched", got, uri)
}
}
func TestSrrdbResolveRelativeDays(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{
name: "today",
uri: "category:flac/date:today/page:1",
want: "category:flac/date:2026-09-30/page:1",
},
{
name: "yesterday",
uri: "category:flac/date:yesterday/page:1",
want: "category:flac/date:2026-09-29/page:1",
},
{
name: "tomorrow",
uri: "category:flac/date:tomorrow/page:1",
want: "category:flac/date:2026-10-01/page:1",
},
{
name: "auto belongs to the walk, not here",
uri: "category:flac/date:auto/page:1",
want: "category:flac/date:auto/page:1",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := srrdbResolveRelativeDays(test.uri, srrdbTestNow); got != test.want {
t.Errorf("srrdbResolveRelativeDays(%q) = %q, want %q", test.uri, got, test.want)
}
})
}
}
+62
View File
@@ -0,0 +1,62 @@
package repository
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
"git.kplus.net.ua/yevhen/resource-scraper/types/resource"
)
// srrdbNormalizedPath turns a search path into a file name. The two characters
// that carry structure in a path are swapped for look-alikes, so the name still
// reads as the query it came from and can be reversed character by character.
func srrdbNormalizedPath(uri string) string {
return strings.NewReplacer(":", ":", "/", "∕").Replace(uri) + ".json"
}
// srrdbDumpPath is the guard file of a search path inside srrdb.dump_dir.
func srrdbDumpPath(uri string) string {
return filepath.Join(viper.GetString("srrdb.dump_dir"), srrdbNormalizedPath(uri))
}
// srrdbExists reports whether a dump file is present. Any stat failure other than
// "not found" is returned as an error: treating it as absent would let the run
// overwrite a dump it has no right to read.
func srrdbExists(path string) (bool, error) {
_, err := os.Stat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, fs.ErrNotExist):
return false, nil
default:
return false, fmt.Errorf("stat srrdb dump %q: %w", path, err)
}
}
// srrdbWriteDump stores the merged answer as indented JSON. It runs only after
// the database is updated, therefore the presence of the file means the whole
// run completed.
//
// The directory is never created here: it holds the cursor of the walk and is made
// once by hand, like the database file.
func srrdbWriteDump(path string, all *resource.SrrdbSearchResponse) error {
data, err := json.MarshalIndent(all, "", " ")
if err != nil {
return fmt.Errorf("encode srrdb dump %q: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write srrdb dump %q: %w", path, err)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package repository
import (
"os"
"path/filepath"
"testing"
)
func TestSrrdbDumpPath(t *testing.T) {
dir := srrdbTestDir(t)
got := srrdbDumpPath("category:flac/date:2026-09-28/page:1")
want := filepath.Join(dir, "category:flac∕date:2026-09-28∕page:1.json")
if got != want {
t.Errorf("srrdbDumpPath = %q, want %q", got, want)
}
}
func TestSrrdbExists(t *testing.T) {
dir := srrdbTestDir(t)
present := filepath.Join(dir, "present.json")
if err := os.WriteFile(present, []byte("{}"), 0o644); err != nil {
t.Fatalf("write %q: %v", present, err)
}
exists, err := srrdbExists(present)
if err != nil {
t.Fatalf("srrdbExists(%q) returned an error: %v", present, err)
}
if !exists {
t.Errorf("srrdbExists(%q) = false, want true", present)
}
exists, err = srrdbExists(filepath.Join(dir, "absent.json"))
if err != nil {
t.Fatalf("srrdbExists on a missing file returned an error: %v", err)
}
if exists {
t.Error("srrdbExists on a missing file = true, want false")
}
}
+100
View File
@@ -0,0 +1,100 @@
package repository
import (
"fmt"
"github.com/spf13/viper"
)
const (
// srrdbGroupsKey is the configuration section holding the group rules:
// groups.<column>.<value>: [group, ...].
srrdbGroupsKey = "groups"
// srrdbStatusColumn is the only column the rules may target. Rows are written
// through a fixed statement, so a second column would need a different way of
// building the insert.
srrdbStatusColumn = "status"
// srrdbIgnoreValue marks groups that must not reach the database at all. It is
// deliberately not a status of its own: the table rejects it with a CHECK, and
// an ignored release has no reason to be stored.
srrdbIgnoreValue = "ignore"
)
// srrdbGroupRules is the resolved rule set: the status every configured group
// receives, and the groups skipped before the insert.
type srrdbGroupRules struct {
status map[string]string
ignore map[string]bool
}
// srrdbLoadGroupRules reads groups.status.<value>: [group, ...].
//
// A group listed under two values is a configuration error rather than a question
// of precedence: the values arrive in a map, Go iterates it in an arbitrary order,
// and the status a release receives would then change from run to run.
func srrdbLoadGroupRules() (*srrdbGroupRules, error) {
rules := &srrdbGroupRules{
status: make(map[string]string),
ignore: make(map[string]bool),
}
for column, rawValues := range viper.GetStringMap(srrdbGroupsKey) {
if column != srrdbStatusColumn {
return nil, fmt.Errorf("%s.%s is not a column of srrdb_releases", srrdbGroupsKey, column)
}
values, ok := rawValues.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s.%s must map a value to a list of groups", srrdbGroupsKey, column)
}
for value, rawList := range values {
groups, err := srrdbGroupList(value, rawList)
if err != nil {
return nil, err
}
for _, group := range groups {
if assigned, exists := rules.status[group]; exists {
return nil, fmt.Errorf("group %q is listed under both %q and %q", group, assigned, value)
}
rules.status[group] = value
if value == srrdbIgnoreValue {
rules.ignore[group] = true
}
}
}
}
return rules, nil
}
// srrdbGroupList converts one value's group list. An element that is not a string
// is rejected rather than dropped: YAML reads an unquoted 401 as a number, and
// losing that group silently would look like a rule that simply does not work.
func srrdbGroupList(value string, raw any) ([]string, error) {
switch list := raw.(type) {
case []string:
return list, nil
case []any:
groups := make([]string, 0, len(list))
for _, item := range list {
group, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s.%s.%s holds %v, which is not a group name: quote it in the configuration",
srrdbGroupsKey, srrdbStatusColumn, value, item)
}
groups = append(groups, group)
}
return groups, nil
default:
return nil, fmt.Errorf("%s.%s.%s must be a list of groups", srrdbGroupsKey, srrdbStatusColumn, value)
}
}
+109
View File
@@ -0,0 +1,109 @@
package repository
import (
"strings"
"testing"
"github.com/spf13/viper"
)
func TestSrrdbLoadGroupRules(t *testing.T) {
viper.Reset()
viper.Set("groups", map[string]any{
"status": map[string]any{
"neformat": []string{"SHGZ"},
"remove": []any{"BIGLOVE", "CT"},
"ignore": []string{"BABAS"},
},
})
rules, err := srrdbLoadGroupRules()
if err != nil {
t.Fatalf("srrdbLoadGroupRules: %v", err)
}
if got := rules.status["SHGZ"]; got != "neformat" {
t.Errorf("status[SHGZ] = %q, want neformat", got)
}
if got := rules.status["CT"]; got != "remove" {
t.Errorf("status[CT] = %q, want remove", got)
}
if !rules.ignore["BABAS"] {
t.Error("BABAS must be ignored")
}
if rules.ignore["CT"] {
t.Error("CT must not be ignored")
}
}
// The regression this guards: viper lowercases keys, and if the group names were
// read as keys, SHGZ would arrive as shgz and would never match a release line.
func TestSrrdbLoadGroupRulesKeepsGroupCase(t *testing.T) {
viper.Reset()
viper.Set("groups", map[string]any{
"status": map[string]any{
"neformat": []string{"SHGZ", "ENViED"},
},
})
rules, err := srrdbLoadGroupRules()
if err != nil {
t.Fatalf("srrdbLoadGroupRules: %v", err)
}
for _, group := range []string{"SHGZ", "ENViED"} {
if _, exists := rules.status[group]; !exists {
t.Errorf("group %q was not registered with its original case", group)
}
}
}
func TestSrrdbLoadGroupRulesRejectsGroupInTwoLists(t *testing.T) {
viper.Reset()
viper.Set("groups", map[string]any{
"status": map[string]any{
"neformat": []string{"SHGZ"},
"remove": []string{"SHGZ"},
},
})
_, err := srrdbLoadGroupRules()
if err == nil {
t.Fatal("a group listed under two values must be rejected")
}
for _, want := range []string{"SHGZ", "neformat", "remove"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name %q", err, want)
}
}
}
func TestSrrdbLoadGroupRulesRejectsNumericGroup(t *testing.T) {
viper.Reset()
viper.Set("groups", map[string]any{
"status": map[string]any{
"ignore": []any{401},
},
})
if _, err := srrdbLoadGroupRules(); err == nil {
t.Error("an unquoted numeric group must be rejected instead of dropped silently")
}
}
func TestSrrdbLoadGroupRulesRejectsUnknownColumn(t *testing.T) {
viper.Reset()
viper.Set("groups", map[string]any{
"nfo": map[string]any{
"x": []string{"Y"},
},
})
if _, err := srrdbLoadGroupRules(); err == nil {
t.Error("a rule targeting a column other than status must be rejected")
}
}
+77
View File
@@ -0,0 +1,77 @@
package repository
import (
"encoding/json"
"log/slog"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
// srrdbAppendJSONL adds one object as a single line to a .jsonl log. A missing
// or failing log never changes the outcome of the run itself.
func srrdbAppendJSONL(key string, record map[string]any) {
path := viper.GetString(key)
if path == "" {
return
}
data, err := json.Marshal(record)
if err != nil {
slog.Error("encode srrdb log record", "key", key, "err", err)
return
}
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
slog.Error("open srrdb log", "path", path, "err", err)
return
}
defer func() {
if cerr := file.Close(); cerr != nil {
slog.Error("close srrdb log", "path", path, "err", cerr)
}
}()
if _, err := file.Write(append(data, '\n')); err != nil {
slog.Error("write srrdb log", "path", path, "err", err)
}
}
// srrdbLogStatus appends the outcome of one finished run to srrdb.log.
func srrdbLogStatus(uri string, stored, reported int) {
srrdbAppendJSONL("srrdb.log", map[string]any{
"time": time.Now().Format(srrdbTimestamp),
"uri": uri,
"stored": stored,
"reported": reported,
})
}
// srrdbLogWarning records warnings reported by the API. They share the failure
// file but carry their own kind, because the endpoint answers an unrecognized
// keyword with HTTP 200 and an empty result set: the text is the only trace of a
// bad path, and it must stay distinguishable from a real breakdown.
func srrdbLogWarning(uri string, warnings []string) {
srrdbAppendJSONL("srrdb.error_log", map[string]any{
"kind": "warning",
"time": time.Now().Format(srrdbTimestamp),
"uri": uri,
"warnings": strings.Join(warnings, "; "),
})
}
// srrdbLogError appends one failure to srrdb.error_log.
func srrdbLogError(uri string, err error) {
srrdbAppendJSONL("srrdb.error_log", map[string]any{
"kind": "error",
"time": time.Now().Format(srrdbTimestamp),
"uri": uri,
"error": err.Error(),
})
}
+61
View File
@@ -0,0 +1,61 @@
package repository
import (
"regexp"
"strings"
"git.kplus.net.ua/yevhen/resource-scraper/types/model"
"git.kplus.net.ua/yevhen/resource-scraper/types/resource"
)
// srrdbGroupSegment cuts the group off the tail of a release line, which always
// sits last. Compiled once at package level instead of per record.
//
// The pattern takes the trailing word, so a group whose own name contains a
// hyphen or a dot would lose everything before that character. Nothing in the
// line tells such a name apart from the tokens in front of it, so widening the
// character class without a list of real group names would only start eating the
// title.
var srrdbGroupSegment = regexp.MustCompile(`-(\w+)$`)
// srrdbCategory reads the value of the category: segment of a search path, which
// is guaranteed to be present by the time this is called.
func srrdbCategory(uri string) string {
_, rest, ok := strings.Cut(uri, "category:")
if !ok {
return ""
}
value, _, _ := strings.Cut(rest, "/")
return value
}
// srrdbRow maps one API record onto a table row. status, nfo, content and
// synced_at stay nil and are stored as NULL.
func srrdbRow(result resource.SrrdbResult, run, category string) model.SrrdbRelease {
return model.SrrdbRelease{
Release: result.Release,
Date: result.Date,
HasNfo: result.HasNFO,
HasSrs: result.HasSRS,
IsForeign: result.IsForeign,
ImdbID: result.ImdbID,
Size: result.Size,
GroupName: srrdbGroupName(result.Release),
Category: category,
CreatedAt: run,
}
}
// srrdbGroupName returns the trailing group of a release line, or an empty
// string when the tail is not a plain word. The nil check is deliberate:
// FindStringSubmatch returns nil on no match, and indexing it directly panics.
func srrdbGroupName(release string) string {
match := srrdbGroupSegment.FindStringSubmatch(release)
if match == nil {
return ""
}
return match[1]
}
+269
View File
@@ -0,0 +1,269 @@
package repository
import (
"encoding/json"
"testing"
"git.kplus.net.ua/yevhen/resource-scraper/types/resource"
)
func TestSrrdbNormalizedPath(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{
name: "full search path",
uri: "category:flac/date:2026-09-28/page:1",
want: "category:flac∕date:2026-09-28∕page:1.json",
},
{
name: "single segment",
uri: "category:flac",
want: "category:flac.json",
},
{
name: "empty path",
uri: "",
want: ".json",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := srrdbNormalizedPath(test.uri); got != test.want {
t.Errorf("srrdbNormalizedPath(%q) = %q, want %q", test.uri, got, test.want)
}
})
}
}
func TestSrrdbGroupName(t *testing.T) {
tests := []struct {
name string
release string
want string
}{
{
name: "music release",
release: "A-Reis-Aetherial_Flow-16BIT-WEB-FLAC-2025-NeonDreams",
want: "NeonDreams",
},
{
name: "group containing digits",
release: "Old_Man-Grim_Tales-16BIT-WEB-FLAC-2020-86D",
want: "86D",
},
{
name: "two tokens only",
release: "Artist-Title",
want: "Title",
},
{
// The pattern takes the trailing word, so a group that has a hyphen
// in its own name loses everything before it. Pinned here on purpose:
// it is the known limitation, and this test will fail the moment the
// pattern is improved.
name: "hyphen inside the group",
release: "Artist-Title-16BIT-WEB-FLAC-2024-MOON-BLOOD",
want: "BLOOD",
},
{
name: "trailing hyphen",
release: "Artist-Title-FLAC-2024-",
want: "",
},
{
name: "empty line",
release: "",
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := srrdbGroupName(test.release); got != test.want {
t.Errorf("srrdbGroupName(%q) = %q, want %q", test.release, got, test.want)
}
})
}
}
func TestSrrdbCategory(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{
name: "flac with further segments",
uri: "category:flac/date:2026-09-28/page:1",
want: "flac",
},
{
name: "category alone",
uri: "category:mp3",
want: "mp3",
},
{
name: "no category segment",
uri: "date:2026-09-28/page:1",
want: "",
},
{
name: "empty path",
uri: "",
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := srrdbCategory(test.uri); got != test.want {
t.Errorf("srrdbCategory(%q) = %q, want %q", test.uri, got, test.want)
}
})
}
}
func TestSrrdbStartPage(t *testing.T) {
tests := []struct {
name string
uri string
wantPage int
wantPaginated bool
}{
{
name: "first page",
uri: "category:flac/date:2026-09-28/page:1",
wantPage: 1,
wantPaginated: true,
},
{
name: "later page",
uri: "category:flac/date:2026-09-28/page:7",
wantPage: 7,
wantPaginated: true,
},
{
name: "no page segment",
uri: "category:flac/date:2026-09-28",
wantPage: 1,
wantPaginated: false,
},
{
name: "zero page falls back to one",
uri: "category:flac/date:2026-09-28/page:0",
wantPage: 1,
wantPaginated: true,
},
{
name: "non numeric page is not a page segment",
uri: "category:flac/date:2026-09-28/page:abc",
wantPage: 1,
wantPaginated: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
page, paginated := srrdbStartPage(test.uri)
if page != test.wantPage || paginated != test.wantPaginated {
t.Errorf("srrdbStartPage(%q) = %d, %t, want %d, %t",
test.uri, page, paginated, test.wantPage, test.wantPaginated)
}
})
}
}
func TestSrrdbWithPage(t *testing.T) {
tests := []struct {
name string
uri string
page int
paginated bool
want string
}{
{
name: "swaps the page segment",
uri: "category:flac/date:2026-09-28/page:3",
page: 4,
paginated: true,
want: "category:flac/date:2026-09-28/page:4",
},
{
name: "single request keeps the path untouched",
uri: "category:flac/date:2026-09-28/page:3",
page: 4,
paginated: false,
want: "category:flac/date:2026-09-28/page:3",
},
{
name: "no page segment to swap",
uri: "category:flac/date:2026-09-28",
page: 2,
paginated: true,
want: "category:flac/date:2026-09-28",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := srrdbWithPage(test.uri, test.page, test.paginated); got != test.want {
t.Errorf("srrdbWithPage(%q, %d, %t) = %q, want %q",
test.uri, test.page, test.paginated, got, test.want)
}
})
}
}
func TestSrrdbCollectKeepsFirstPageEnvelope(t *testing.T) {
collected := &resource.SrrdbSearchResponse{}
srrdbCollect(collected, &resource.SrrdbSearchResponse{
ResultsCount: 155,
Query: json.RawMessage(`[21]`),
Results: []resource.SrrdbResult{
{Release: "First-Release-16BIT-WEB-FLAC-2026-GROUP"},
{Release: "Second-Release-16BIT-WEB-FLAC-2026-GROUP"},
},
Warnings: []string{"first warning"},
})
srrdbCollect(collected, &resource.SrrdbSearchResponse{
ResultsCount: 999,
Query: json.RawMessage(`[22]`),
Results: []resource.SrrdbResult{
{Release: "Third-Release-16BIT-WEB-FLAC-2026-GROUP"},
},
Warnings: []string{"second warning"},
})
if collected.ResultsCount != 155 {
t.Errorf("ResultsCount = %d, want 155: only the first page may set it", collected.ResultsCount)
}
if got := string(collected.Query); got != "[21]" {
t.Errorf("Query = %s, want [21]: only the first page may set it", got)
}
if len(collected.Results) != 3 {
t.Errorf("len(Results) = %d, want 3", len(collected.Results))
}
if len(collected.Warnings) != 2 {
t.Errorf("len(Warnings) = %d, want 2", len(collected.Warnings))
}
}
func TestSrrdbCollectZeroCountOnFirstPageIsNotSticky(t *testing.T) {
collected := &resource.SrrdbSearchResponse{}
srrdbCollect(collected, &resource.SrrdbSearchResponse{})
srrdbCollect(collected, &resource.SrrdbSearchResponse{ResultsCount: 42})
if collected.ResultsCount != 42 {
t.Errorf("ResultsCount = %d, want 42: a zero count must not freeze the envelope", collected.ResultsCount)
}
}
+83
View File
@@ -0,0 +1,83 @@
package table
import (
"fmt"
"github.com/jmoiron/sqlx"
"git.kplus.net.ua/yevhen/resource-scraper/types/model"
)
// srrdbUpsert keys on the "release" primary key: a repeated search refreshes the
// existing row instead of inserting a copy. synced_at is stamped with the same
// run timestamp as created_at, so the two differ only on a release that was
// already stored by an earlier run.
const srrdbUpsert = `
INSERT INTO srrdb_releases
("release", date, has_nfo, has_srs, is_foreign, imdb_id, size, group_name, category, status, created_at)
VALUES
(:release, :date, :has_nfo, :has_srs, :is_foreign, :imdb_id, :size, :group_name, :category, :status, :created_at)
ON CONFLICT("release") DO UPDATE SET
date = excluded.date,
has_nfo = excluded.has_nfo,
has_srs = excluded.has_srs,
is_foreign = excluded.is_foreign,
imdb_id = excluded.imdb_id,
size = excluded.size,
group_name = excluded.group_name,
category = excluded.category,
status = COALESCE(excluded.status, status),
synced_at = excluded.created_at`
const srrdbUpsertDay = `
INSERT INTO srrdb_log
("day", "category", "found", "stored", "ignored", "run_at")
VALUES
(:day, :category, :found, :stored, :ignored, :run_at)
ON CONFLICT("day", "category") DO UPDATE SET
found = excluded.found,
stored = excluded.stored,
ignored = excluded.ignored,
run_at = excluded.run_at`
// UpsertAllSrrdb writes a whole set in one transaction: either every release
// lands or none does, so a failure halfway through cannot leave a day half
// harvested.
func UpsertAllSrrdb(db *sqlx.DB, rows []model.SrrdbRelease) error {
return UpsertAllSrrdbWithDay(db, rows, nil)
}
func UpsertAllSrrdbWithDay(db *sqlx.DB, rows []model.SrrdbRelease, day *model.SrrdbLog) error {
if len(rows) == 0 && day == nil {
return nil
}
tx, err := db.Beginx()
if err != nil {
return fmt.Errorf("begin srrdb transaction: %w", err)
}
for _, row := range rows {
if _, err := tx.NamedExec(srrdbUpsert, &row); err != nil {
// Rollback failure is not reported: the insert error is the one that
// matters to the caller, and the transaction is gone either way.
_ = tx.Rollback()
return fmt.Errorf("upsert srrdb release %q: %w", row.Release, err)
}
}
if day != nil {
if _, err := tx.NamedExec(srrdbUpsertDay, day); err != nil {
_ = tx.Rollback()
return fmt.Errorf("upsert srrdb day %q/%q: %w", day.Day, day.Category, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit srrdb transaction: %w", err)
}
return nil
}
+2
View File
@@ -10,6 +10,7 @@ type Service struct {
_interface.Prescene _interface.Prescene
_interface.Info _interface.Info
_interface.ShareTheBrutality _interface.ShareTheBrutality
_interface.Srrdb
} }
func New(repos *repository.Repository) *Service { func New(repos *repository.Repository) *Service {
@@ -18,5 +19,6 @@ func New(repos *repository.Repository) *Service {
Prescene: NewPresceneService(repos.Prescene), Prescene: NewPresceneService(repos.Prescene),
Info: NewInfoService(), Info: NewInfoService(),
ShareTheBrutality: NewShareTheBrutalityService(repos.ShareTheBrutality), ShareTheBrutality: NewShareTheBrutalityService(repos.ShareTheBrutality),
Srrdb: NewSrrdbService(repos.Srrdb),
} }
} }
+17
View File
@@ -0,0 +1,17 @@
package service
import (
"git.kplus.net.ua/yevhen/resource-scraper/types/interface"
)
type SrrdbService struct {
repo _interface.Srrdb
}
func NewSrrdbService(repo _interface.Srrdb) *SrrdbService {
return &SrrdbService{repo: repo}
}
func (s *SrrdbService) Search(uri string) (int, error) {
return s.repo.Search(uri)
}
BIN
View File
Binary file not shown.
+1
View File
@@ -6,6 +6,7 @@ const (
ScopeWeb string = "web" ScopeWeb string = "web"
ScopeInfo string = "info" ScopeInfo string = "info"
ScopeShareTheBrutality string = "stb" ScopeShareTheBrutality string = "stb"
ScopeSrrdb string = "srrdb"
) )
const ( const (
+2
View File
@@ -10,6 +10,8 @@ const (
FlagScopeEnable = "scope-enable" FlagScopeEnable = "scope-enable"
FlagSingleUri = "single-uri" FlagSingleUri = "single-uri"
FlagEnv = "env" FlagEnv = "env"
FlagCreate = "create"
FlagSleep = "sleep"
DefaultEnvProd = "prod" DefaultEnvProd = "prod"
DefaultEnvDev = "devel" DefaultEnvDev = "devel"
+4
View File
@@ -22,4 +22,8 @@ type MetalArchiveInterface interface {
GetSearchResults(searchResults []model.ExternalSources) ([]model.ExternalSources, error) GetSearchResults(searchResults []model.ExternalSources) ([]model.ExternalSources, error)
} }
type Srrdb interface {
Search(uri string) (int, error)
}
type Info interface{} type Info interface{}
+21
View File
@@ -0,0 +1,21 @@
package model
import "database/sql"
// ScnlogMeta — структура, що кладеться в external_sources.meta (compact JSON).
type ScnlogMeta struct {
SizeMb *float64 `json:"size_mb"`
Genre *string `json:"genre"`
Artist *[]string `json:"artist"`
Tracks *int `json:"tracks"`
Duration *string `json:"duration"`
}
// ScnlogSource — рядок вибірки останнього необробленого prescene-джерела.
type ScnlogSource struct {
Id int64 `db:"id"`
TypeId int64 `db:"type_id"`
Title string `db:"title"`
Meta sql.NullString `db:"meta"`
ExSource string `db:"eXsource"`
}
+34
View File
@@ -0,0 +1,34 @@
package model
// SrrdbRelease is one row of the srrdb_releases table described by
// internal/db/srrdb.sql. Release is the primary key, therefore a repeated
// search rewrites the previous row of the same release instead of inserting
// a copy. Columns left nil are stored as NULL.
type SrrdbRelease struct {
Release string `db:"release"`
Date string `db:"date"`
HasNfo string `db:"has_nfo"`
HasSrs string `db:"has_srs"`
IsForeign string `db:"is_foreign"`
ImdbID *string `db:"imdb_id"`
Size int64 `db:"size"`
GroupName string `db:"group_name"`
Category string `db:"category"`
Status *string `db:"status"`
Nfo []byte `db:"nfo"`
Content *string `db:"content"`
CreatedAt string `db:"created_at"`
SyncedAt *string `db:"synced_at"`
}
// SrrdbLog is one row of the srrdb_log table: per-day, per-category
// accounting of a harvest run. Written in the same transaction as the
// releases, so a day marked there always matches what landed in srrdb_releases.
type SrrdbLog struct {
Day string `db:"day"`
Category string `db:"category"`
Found int `db:"found"`
Stored int `db:"stored"`
Ignored int `db:"ignored"`
RunAt string `db:"run_at"`
}
+26
View File
@@ -0,0 +1,26 @@
package resource
import "encoding/json"
// SrrdbSearchResponse is the payload of GET https://api.srrdb.com/v1/search/{path}.
// Query stays raw because the API returns a heterogeneous array there: category
// ids as numbers, search terms as strings.
type SrrdbSearchResponse struct {
Results []SrrdbResult `json:"results"`
ResultsCount int `json:"resultsCount"`
Warnings []string `json:"warnings"`
Query json.RawMessage `json:"query"`
}
// SrrdbResult is a single release as reported by the search endpoint.
// HasNFO, HasSRS and IsForeign arrive as the literal strings "yes" and "no";
// ImdbID is null for music releases.
type SrrdbResult struct {
Release string `json:"release"`
Date string `json:"date"`
HasNFO string `json:"hasNFO"`
HasSRS string `json:"hasSRS"`
IsForeign string `json:"isForeign"`
ImdbID *string `json:"imdbId"`
Size int64 `json:"size"`
}