refactored & audited, real analizer added
This commit is contained in:
@@ -14,3 +14,4 @@ vendor/
|
|||||||
config/secret.yaml
|
config/secret.yaml
|
||||||
*.md
|
*.md
|
||||||
mdb
|
mdb
|
||||||
|
internal/releaseparser/resources/*
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser/groups/moonblood"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
stats, err := moonblood.AnalyzeGroup()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(stats, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package moonblood
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GroupStats struct {
|
||||||
|
Total int
|
||||||
|
YearDistribution map[int]int
|
||||||
|
SourceDistribution map[string]int
|
||||||
|
BitDepthDistribution map[string]int
|
||||||
|
ResolutionDistribution map[string]int
|
||||||
|
LanguageDistribution map[string]int
|
||||||
|
EditionDistribution map[string]int
|
||||||
|
UnknownTokens []releaseparser.UnknownTokenStats
|
||||||
|
AvgConfidence float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnalyzeGroup() (GroupStats, error) {
|
||||||
|
stats := GroupStats{
|
||||||
|
YearDistribution: make(map[int]int),
|
||||||
|
SourceDistribution: make(map[string]int),
|
||||||
|
BitDepthDistribution: make(map[string]int),
|
||||||
|
ResolutionDistribution: make(map[string]int),
|
||||||
|
LanguageDistribution: make(map[string]int),
|
||||||
|
EditionDistribution: make(map[string]int),
|
||||||
|
UnknownTokens: []releaseparser.UnknownTokenStats{},
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open("internal/releaseparser/resources/moonblood.txt")
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
totalConfidence := 0.0
|
||||||
|
|
||||||
|
unknownMap := make(map[string]*releaseparser.UnknownTokenStats)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Parse(line)
|
||||||
|
stats.Total++
|
||||||
|
|
||||||
|
if result.Year != 0 {
|
||||||
|
stats.YearDistribution[result.Year]++
|
||||||
|
}
|
||||||
|
if result.Source != "" {
|
||||||
|
stats.SourceDistribution[result.Source]++
|
||||||
|
}
|
||||||
|
if result.BitDepth != "" {
|
||||||
|
stats.BitDepthDistribution[result.BitDepth]++
|
||||||
|
}
|
||||||
|
if result.Resolution != "" {
|
||||||
|
stats.ResolutionDistribution[result.Resolution]++
|
||||||
|
}
|
||||||
|
if result.Language != "" {
|
||||||
|
stats.LanguageDistribution[result.Language]++
|
||||||
|
}
|
||||||
|
if result.Edition != "" {
|
||||||
|
stats.EditionDistribution[result.Edition]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, unknown := range result.UnknownTokens {
|
||||||
|
if _, exists := unknownMap[unknown]; !exists {
|
||||||
|
unknownMap[unknown] = &releaseparser.UnknownTokenStats{
|
||||||
|
Token: unknown,
|
||||||
|
Count: 0,
|
||||||
|
Releases: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unknownMap[unknown].Count++
|
||||||
|
unknownMap[unknown].Releases = append(unknownMap[unknown].Releases, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalConfidence += result.Confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to slice and sort by count
|
||||||
|
for _, tokenStats := range unknownMap {
|
||||||
|
stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(stats.UnknownTokens, func(i, j int) bool {
|
||||||
|
return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
if stats.Total > 0 {
|
||||||
|
stats.AvgConfidence = totalConfidence / float64(stats.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# {bit_depth}
|
||||||
|
16BIT
|
||||||
|
24BIT
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
REPACK
|
||||||
|
REAL_REPACK
|
||||||
|
READNFO
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# {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
|
||||||
|
REISSUE_DEMO
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# {format} — аудіоформат
|
||||||
|
# один токен на рядок, регістр не має значення
|
||||||
|
FLAC
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# {language} — мовні маркери
|
||||||
|
DE
|
||||||
|
FR
|
||||||
|
ES
|
||||||
|
SK
|
||||||
|
FI
|
||||||
|
SE
|
||||||
|
BR
|
||||||
|
PL
|
||||||
|
NL
|
||||||
|
NO
|
||||||
|
PT
|
||||||
|
IT
|
||||||
|
HU
|
||||||
|
DK
|
||||||
|
AT
|
||||||
|
IS
|
||||||
|
UA
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# {resolution} — частота дискретизації
|
||||||
|
44KHZ
|
||||||
|
44.1KHZ
|
||||||
|
48KHZ
|
||||||
|
88.2KHZ
|
||||||
|
96KHZ
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# {source} — джерело релізу
|
||||||
|
# VINYL сюди входить як source (за домовленістю, не edition)
|
||||||
|
WEB
|
||||||
|
CD
|
||||||
|
2CD
|
||||||
|
CDEP
|
||||||
|
VINYL
|
||||||
|
TAPE
|
||||||
|
CDR
|
||||||
|
17CD
|
||||||
|
3CD
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package moonblood
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
var yearRegexp = regexp.MustCompile(`^(19|20)\d{2}$`)
|
||||||
|
|
||||||
|
func Parse(raw string) releaseparser.ParsedRelease {
|
||||||
|
result := releaseparser.ParsedRelease{
|
||||||
|
Raw: strings.TrimSpace(raw),
|
||||||
|
Warnings: []string{},
|
||||||
|
UnknownTokens: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := split(raw)
|
||||||
|
if len(tokens) < 5 {
|
||||||
|
result.Warnings = append(result.Warnings, "too few tokens")
|
||||||
|
result.Confidence = 0.2
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Останній токен — група
|
||||||
|
result.Group = tokens[len(tokens)-1]
|
||||||
|
if result.Group != "MOONBLOOD" {
|
||||||
|
result.Warnings = append(result.Warnings, "unexpected group")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Передостанній — рік
|
||||||
|
last := len(tokens) - 2
|
||||||
|
if yearRegexp.MatchString(tokens[last]) {
|
||||||
|
result.Year, _ = strconv.Atoi(tokens[last])
|
||||||
|
last--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рухаємось вліво по хвосту: format, source, bit_depth, resolution, language, edition.
|
||||||
|
// Кожен слот бере токен лише зі свого словника (регістронезалежно).
|
||||||
|
// Невідомий токен: репортимо лише якщо ліворуч ще є відомий хвостовий маркер
|
||||||
|
// (токен між двома маркерами = сміття в хвості). Інакше це початок core.
|
||||||
|
tailLoop:
|
||||||
|
for last >= 0 {
|
||||||
|
token := strings.ToUpper(tokens[last])
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case Profile.FormatTokens[token]:
|
||||||
|
if result.Format == "" {
|
||||||
|
result.Format = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
case Profile.SourceTokens[token]:
|
||||||
|
if result.Source == "" {
|
||||||
|
result.Source = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
case Profile.BitDepthTokens[token]:
|
||||||
|
if result.BitDepth == "" {
|
||||||
|
result.BitDepth = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
case Profile.ResolutionTokens[token]:
|
||||||
|
if result.Resolution == "" {
|
||||||
|
result.Resolution = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
case Profile.LanguageMarkers[token]:
|
||||||
|
if result.Language == "" {
|
||||||
|
result.Language = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
case Profile.EditionTokens[token]:
|
||||||
|
if result.Edition == "" {
|
||||||
|
result.Edition = token
|
||||||
|
}
|
||||||
|
last--
|
||||||
|
|
||||||
|
default:
|
||||||
|
if hasKnownTailToken(tokens[:last]) {
|
||||||
|
result.UnknownTokens = append(result.UnknownTokens, tokens[last])
|
||||||
|
last--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break tailLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core = artist-title (все, що залишилось ліворуч)
|
||||||
|
coreTokens := tokens[:last+1]
|
||||||
|
result.Core = strings.Join(coreTokens, "-")
|
||||||
|
|
||||||
|
if len(coreTokens) >= 2 {
|
||||||
|
result.Artist = coreTokens[0]
|
||||||
|
result.Title = strings.Join(coreTokens[1:], "-")
|
||||||
|
} else if len(coreTokens) == 1 {
|
||||||
|
result.Artist = coreTokens[0]
|
||||||
|
result.Title = ""
|
||||||
|
result.Warnings = append(result.Warnings, "title not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
score(&result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func split(raw string) []string {
|
||||||
|
parts := strings.Split(strings.TrimSpace(raw), "-")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if part = strings.TrimSpace(part); part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasKnownTailToken reports whether any token is a known tail marker
|
||||||
|
// (format, source, bit_depth, resolution, edition, language or a year).
|
||||||
|
func hasKnownTailToken(tokens []string) bool {
|
||||||
|
for _, tok := range tokens {
|
||||||
|
up := strings.ToUpper(tok)
|
||||||
|
if Profile.FormatTokens[up] || Profile.SourceTokens[up] ||
|
||||||
|
Profile.BitDepthTokens[up] || Profile.ResolutionTokens[up] ||
|
||||||
|
Profile.EditionTokens[up] || Profile.LanguageMarkers[up] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if yearRegexp.MatchString(up) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func score(result *releaseparser.ParsedRelease) {
|
||||||
|
score := 0.0
|
||||||
|
if result.Group == "MOONBLOOD" {
|
||||||
|
score += 0.25
|
||||||
|
}
|
||||||
|
if result.Year != 0 {
|
||||||
|
score += 0.20
|
||||||
|
}
|
||||||
|
if result.Format != "" {
|
||||||
|
score += 0.15
|
||||||
|
}
|
||||||
|
if result.Source != "" {
|
||||||
|
score += 0.15
|
||||||
|
}
|
||||||
|
if result.Artist != "" {
|
||||||
|
score += 0.15
|
||||||
|
}
|
||||||
|
if len(result.Warnings) == 0 {
|
||||||
|
score += 0.10
|
||||||
|
}
|
||||||
|
if len(result.UnknownTokens) > 0 {
|
||||||
|
score -= 0.10
|
||||||
|
}
|
||||||
|
if score > 1 {
|
||||||
|
score = 1
|
||||||
|
}
|
||||||
|
if score < 0 {
|
||||||
|
score = 0
|
||||||
|
}
|
||||||
|
result.Confidence = score
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package moonblood
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMoonblood(t *testing.T) {
|
||||||
|
file, err := os.Open("../../resources/moonblood.txt")
|
||||||
|
if err != nil {
|
||||||
|
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) {
|
||||||
|
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
|
||||||
|
if r.Artist != "Fane" {
|
||||||
|
t.Errorf("Artist = %q, want %q", r.Artist, "Fane")
|
||||||
|
}
|
||||||
|
if r.Title != "Dragged_Through_the_Unending" {
|
||||||
|
t.Errorf("Title = %q, want %q", r.Title, "Dragged_Through_the_Unending")
|
||||||
|
}
|
||||||
|
if r.Year != 2025 || r.Format != "FLAC" || r.Source != "WEB" || r.BitDepth != "24BIT" {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
if r.Confidence < 0.99 {
|
||||||
|
t.Errorf("Confidence = %.2f, want ~1.0", r.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEditionAndDemo(t *testing.T) {
|
||||||
|
r := Parse("Satans_Grind-Rotten_Demo-DEMO-16BIT-WEB-FLAC-2016-MOONBLOOD")
|
||||||
|
if r.Edition != "DEMO" {
|
||||||
|
t.Errorf("Edition = %q, want %q", r.Edition, "DEMO")
|
||||||
|
}
|
||||||
|
if r.Artist != "Satans_Grind" || r.Title != "Rotten_Demo" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArtistWithHyphen(t *testing.T) {
|
||||||
|
// Artist itself contains a hyphen; no part of it may leak into UnknownTokens.
|
||||||
|
r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD")
|
||||||
|
if r.Artist != "Ultra" || r.Title != "Violence-All_My_Life" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence-All_My_Life", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLanguageAndCDSource(t *testing.T) {
|
||||||
|
r := Parse("Burn_Thou_in_Effigy-Memorias_Postumas_do_Imperador-BR-CD-FLAC-2026-MOONBLOOD")
|
||||||
|
if r.Language != "BR" || r.Source != "CD" || r.Year != 2026 || r.Format != "FLAC" {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNoYear(t *testing.T) {
|
||||||
|
// Without a year the tail still parses; group stays the last token.
|
||||||
|
r := Parse("Fane-Gallant_Shroud-EP-16BIT-WEB-FLAC-MOONBLOOD")
|
||||||
|
if r.Group != "MOONBLOOD" || r.Year != 0 {
|
||||||
|
t.Errorf("Group/Year = %q/%d", r.Group, r.Year)
|
||||||
|
}
|
||||||
|
if r.Edition != "EP" || r.Source != "WEB" || r.Format != "FLAC" {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if r.Artist != "Fane" || r.Title != "Gallant_Shroud" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResolutionSlot(t *testing.T) {
|
||||||
|
r := Parse("Ice_Nine_Kills-Last_Chance_To_Make_Amends-REMASTERED-24BIT-44KHZ-WEB-FLAC-2024-FLACCiD")
|
||||||
|
if r.Group != "FLACCiD" {
|
||||||
|
t.Errorf("Group = %q, want FLACCiD", r.Group)
|
||||||
|
}
|
||||||
|
if r.Resolution != "44KHZ" {
|
||||||
|
t.Errorf("Resolution = %q, want 44KHZ", r.Resolution)
|
||||||
|
}
|
||||||
|
if r.BitDepth != "24BIT" || r.Edition != "REMASTERED" || r.Source != "WEB" || r.Year != 2024 {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if r.Artist != "Ice_Nine_Kills" || r.Title != "Last_Chance_To_Make_Amends" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVinylIsSource(t *testing.T) {
|
||||||
|
r := Parse("ROTH-Alles_Endet-DE-VINYL-FLAC-2026-TOTENKVLT")
|
||||||
|
if r.Language != "DE" {
|
||||||
|
t.Errorf("Language = %q, want DE", r.Language)
|
||||||
|
}
|
||||||
|
if r.Source != "VINYL" {
|
||||||
|
t.Errorf("Source = %q, want VINYL", r.Source)
|
||||||
|
}
|
||||||
|
if r.Edition != "" {
|
||||||
|
t.Errorf("Edition = %q, want empty (VINYL is source, not edition)", r.Edition)
|
||||||
|
}
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCaseInsensitiveDictionaries(t *testing.T) {
|
||||||
|
r := Parse("Artist-Some_Title-16bit-web-flac-2020-moonblood")
|
||||||
|
if r.BitDepth != "16BIT" || r.Source != "WEB" || r.Format != "FLAC" {
|
||||||
|
t.Errorf("case-insensitive lookup failed: %+v", r)
|
||||||
|
}
|
||||||
|
if r.Artist != "Artist" || r.Title != "Some_Title" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUnknownTailTokenReported(t *testing.T) {
|
||||||
|
// Невідомий токен між двома відомими маркерами → у UnknownTokens.
|
||||||
|
r := Parse("Artist-Some_Title-SPLIT-FOO-16BIT-WEB-FLAC-2025-MOONBLOOD")
|
||||||
|
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" {
|
||||||
|
t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
if r.Edition != "SPLIT" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 {
|
||||||
|
t.Errorf("tail fields wrong: %+v", r)
|
||||||
|
}
|
||||||
|
if r.Artist != "Artist" || r.Title != "Some_Title" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoreTokensNeverReported(t *testing.T) {
|
||||||
|
// Артист/тайтл ніколи не потрапляють в UnknownTokens.
|
||||||
|
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
|
||||||
|
if len(r.UnknownTokens) != 0 {
|
||||||
|
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||||
|
}
|
||||||
|
if r.Artist != "Fane" || r.Title != "Dragged_Through_the_Unending" {
|
||||||
|
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package releaseparser
|
||||||
|
|
||||||
|
type GroupProfile struct {
|
||||||
|
Name string
|
||||||
|
ExpectedTailOrder []string
|
||||||
|
FormatTokens map[string]bool
|
||||||
|
SourceTokens map[string]bool
|
||||||
|
BitDepthTokens map[string]bool
|
||||||
|
ResolutionTokens map[string]bool
|
||||||
|
LanguageMarkers map[string]bool
|
||||||
|
EditionTokens map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedRelease struct {
|
||||||
|
Raw string
|
||||||
|
Group string
|
||||||
|
Year int
|
||||||
|
BitDepth string
|
||||||
|
Resolution string
|
||||||
|
Source string
|
||||||
|
Format string
|
||||||
|
Language string
|
||||||
|
Edition string
|
||||||
|
Core string
|
||||||
|
Artist string
|
||||||
|
Title string
|
||||||
|
UnknownTokens []string
|
||||||
|
Warnings []string
|
||||||
|
Confidence float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnknownTokenStats struct {
|
||||||
|
Token string
|
||||||
|
Count int
|
||||||
|
Releases []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupStats struct {
|
||||||
|
Total int
|
||||||
|
YearDistribution map[int]int
|
||||||
|
SourceDistribution map[string]int
|
||||||
|
BitDepthDistribution map[string]int
|
||||||
|
ResolutionDistribution map[string]int
|
||||||
|
LanguageDistribution map[string]int
|
||||||
|
EditionDistribution map[string]int
|
||||||
|
UnknownTokens []UnknownTokenStats
|
||||||
|
AvgConfidence float64
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user