1
1

Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.

- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы.
- Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов.
- Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов.
- Добавить тесты для нормализации URL в репозиторий RuTracker.
- Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер.
- Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
Dmitriy Fofanov
2025-09-29 20:40:05 +03:00
parent 49bea780aa
commit 72a66f1664
32 changed files with 4073 additions and 22 deletions

View File

@@ -0,0 +1,132 @@
package services
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"audio-catalyst/internal/domain/entities"
)
// AudioBookService сервис для работы с аудиокнигами
type AudioBookService struct{}
// NewAudioBookService создает новый сервис аудиокниг
func NewAudioBookService() *AudioBookService {
return &AudioBookService{}
}
// CreateChapters создает главы из MP3 файлов
func (s *AudioBookService) CreateChapters(mp3Files []string) []entities.Chapter {
// Гарантируем пустой слайс, а не nil (в JSON будет [] вместо null)
chapters := make([]entities.Chapter, 0, len(mp3Files))
// Сортировка файлов по имени
sortedFiles := make([]string, len(mp3Files))
copy(sortedFiles, mp3Files)
sort.Strings(sortedFiles)
currentStart := 0.0
for i, file := range sortedFiles {
fileName := filepath.Base(file)
baseName := strings.TrimSuffix(fileName, ".mp3")
chapterTitle := s.extractChapterTitle(baseName)
// Оцениваем длительность файла в секундах
durSec := s.estimateDuration(file)
if durSec < 0 {
durSec = 0
}
chapter := entities.Chapter{
ID: i,
Start: currentStart,
End: currentStart + durSec,
Title: chapterTitle,
Duration: int(durSec),
}
currentStart += durSec
chapters = append(chapters, chapter)
}
return chapters
}
// extractChapterTitle извлекает название главы из имени файла
func (s *AudioBookService) extractChapterTitle(fileName string) string {
name := strings.TrimSuffix(fileName, ".mp3")
patterns := []string{
`^(\d{1,3})[\s\-_.]*(.*)$`,
`^[Cc]hapter[\s_]*(\d+)(.*)$`,
`^[Гг]лава[\s_]*(\d+)(.*)$`,
`^[Чч]асть[\s_]*(\d+)(.*)$`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(name); len(matches) >= 2 {
number := matches[1]
title := ""
if len(matches) >= 3 {
title = strings.TrimSpace(matches[2])
title = strings.TrimLeft(title, "-_. ")
}
if title != "" {
return fmt.Sprintf("%s - %s", number, title)
}
return number
}
}
return name
}
// estimateDuration оценивает продолжительность MP3 файла (в секундах)
// Приближение: 1 MB ≈ 0.86 минуты (≈51.6 секунды) при средних битрейтах
func (s *AudioBookService) estimateDuration(filePath string) float64 {
info, err := os.Stat(filePath)
if err != nil {
return 0
}
// Размер файла в MB
sizeMB := float64(info.Size()) / (1024.0 * 1024.0)
// Примерная продолжительность в минутах
estimatedMinutes := sizeMB * 0.86
return estimatedMinutes * 60.0
}
// CleanTitle очищает название книги от технической информации
func (s *AudioBookService) CleanTitle(title string) string {
// Удаляем информацию в квадратных скобках в конце
title = regexp.MustCompile(`\s*\[.*?\]\s*$`).ReplaceAllString(title, "")
// Удаляем информацию в круглых скобках в конце
title = regexp.MustCompile(`\s*\(.*?\)\s*$`).ReplaceAllString(title, "")
// Удаляем технические обозначения
techPatterns := []string{
`\s*MP3\s*$`,
`\s*FLAC\s*$`,
`\s*\d+\s*kbps\s*$`,
`\s*\d+\s*кбит/с\s*$`,
`\s*VBR\s*$`,
`\s*CBR\s*$`,
`\s*\d{4}\s*$`,
}
for _, pattern := range techPatterns {
title = regexp.MustCompile(`(?i)`+pattern).ReplaceAllString(title, "")
}
// Убираем лишние пробелы
title = strings.TrimSpace(title)
title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ")
return title
}

View File

@@ -0,0 +1,66 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestCreateChaptersAndDurations(t *testing.T) {
dir := t.TempDir()
files := []string{"02.mp3", "01.mp3"}
var paths []string
// создаём разные размеры для разной длительности
for i, f := range files {
p := filepath.Join(dir, f)
data := make([]byte, (i+1)*1024*1024) // 1MB, 2MB
if err := os.WriteFile(p, data, 0644); err != nil {
t.Fatalf("write: %v", err)
}
paths = append(paths, p)
}
svc := NewAudioBookService()
chapters := svc.CreateChapters(paths)
if len(chapters) != 2 {
t.Fatalf("ожидалось 2 главы, получено %d", len(chapters))
}
// порядок по имени: 01.mp3, 02.mp3
if chapters[0].Title != "01" || chapters[1].Title != "02" {
t.Errorf("неверные названия глав: %+v", chapters)
}
if !(chapters[0].Duration > 40 && chapters[1].Duration > chapters[0].Duration) {
t.Errorf("неверные длительности: %+v", chapters)
}
if !(chapters[0].Start == 0 && chapters[0].End < chapters[1].Start) {
t.Errorf("неверные интервалы: %+v", chapters)
}
}
func TestExtractChapterTitlePatterns(t *testing.T) {
svc := NewAudioBookService()
cases := map[string]string{
"01 - Введение": "01 - Введение",
"001_Первая_глава": "001 - Первая_глава",
"Chapter 05 - Название": "05 - Название",
"Глава 3 - Важная информация": "3 - Важная информация",
"Часть 10": "10",
"простоеазвание": "простоеазвание",
"15": "15",
}
for in, exp := range cases {
got := svc.extractChapterTitle(in)
if got != exp {
t.Errorf("%q => %q, ожидалось %q", in, got, exp)
}
}
}
func TestCleanTitle(t *testing.T) {
svc := NewAudioBookService()
in := "Автор - Книга [MP3, 96 kbps] (2025)"
got := svc.CleanTitle(in)
if got != "Автор - Книга" {
t.Errorf("получено %q", got)
}
}

View File

@@ -0,0 +1,520 @@
package services
import (
"regexp"
"strconv"
"strings"
"audio-catalyst/internal/domain/entities"
"golang.org/x/text/encoding/charmap"
)
// MetadataService сервис для работы с метаданными
type MetadataService struct{}
// NewMetadataService создает новый сервис метаданных
func NewMetadataService() *MetadataService {
return &MetadataService{}
}
// ParseTopicMetadata парсит метаданные со страницы темы RuTracker
func (s *MetadataService) ParseTopicMetadata(htmlContent string, torrent entities.Torrent) (*entities.AudioBookMetadata, error) {
// Декодируем из Windows-1251 в UTF-8
decoder := charmap.Windows1251.NewDecoder()
decodedContent, err := decoder.String(htmlContent)
if err != nil {
decodedContent = htmlContent
}
audioBookService := NewAudioBookService()
cleanTitle := audioBookService.CleanTitle(torrent.Title)
metadata := &entities.AudioBookMetadata{
Tags: []string{},
Chapters: []entities.Chapter{},
Title: cleanTitle,
Subtitle: cleanTitle,
Authors: []string{},
Narrators: []string{},
Series: []string{},
Genres: []string{},
Description: "",
Language: "ru",
Explicit: false,
Abridged: false,
}
// Попытаться извлечь заголовок из HTML: <span style="font-size: 24px; ...">Заголовок</span>
if htmlTitle := s.extractHTMLTitle(decodedContent); htmlTitle != "" {
metadata.Title = htmlTitle
}
// Subtitle: берем из <title> страницы (полный заголовок)
if pageTitle := s.extractPageTitle(decodedContent); pageTitle != "" {
metadata.Subtitle = pageTitle
} else if metadata.Subtitle == "" {
metadata.Subtitle = metadata.Title
}
// Функция для извлечения значения после <span class="post-b">метка</span>: значение
extractPostBValue := func(label string) string {
pattern := `<span[^>]*class="[^"]*post-b[^"]*"[^>]*>` + regexp.QuoteMeta(label) + `</span>:\s*([^<\r\n]+)`
re := regexp.MustCompile(`(?i)` + pattern)
matches := re.FindStringSubmatch(decodedContent)
if len(matches) >= 2 {
value := strings.TrimSpace(matches[1])
return s.decodeHTMLEntities(value)
}
return ""
}
// Извлечение года выпуска
if yearStr := extractPostBValue("Год выпуска"); yearStr != "" {
yearMatch := regexp.MustCompile(`(\d{4})`).FindString(yearStr)
if yearMatch != "" && len(yearMatch) == 4 {
if yearMatch >= "1900" && yearMatch <= "2030" {
yearInt := 0
for _, digit := range yearMatch {
yearInt = yearInt*10 + int(digit-'0')
}
metadata.PublishedYear = &yearInt
}
}
}
// Извлечение автора
s.extractAuthors(extractPostBValue, metadata)
// Извлечение исполнителя (чтеца)
s.extractNarrators(extractPostBValue, metadata)
// Извлечение жанра
if genre := extractPostBValue("Жанр"); genre != "" {
genres := regexp.MustCompile(`[,;]\s*`).Split(genre, -1)
for i, g := range genres {
genres[i] = strings.TrimSpace(g)
}
metadata.Genres = genres
}
// Извлечение тегов (категории форума), например: "[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики"
s.extractTags(decodedContent, metadata)
// Извлечение издательства
s.extractPublisher(extractPostBValue, metadata)
// Извлечение ISBN и ASIN
s.extractISBN(extractPostBValue, decodedContent, metadata)
s.extractASIN(extractPostBValue, decodedContent, metadata)
// Извлечение описания
s.extractDescription(extractPostBValue, decodedContent, metadata)
// Извлечение серии
s.extractSeries(extractPostBValue, decodedContent, torrent, metadata)
// Определяем эксплицитность и сокращенность
s.detectContentFlags(decodedContent, metadata)
return metadata, nil
}
// extractAuthors извлекает авторов
func (s *MetadataService) extractAuthors(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
var authorParts []string
if surname := extractPostBValue("Фамилия автора"); surname != "" {
authorParts = append(authorParts, surname)
}
if name := extractPostBValue("Имя автора"); name != "" {
authorParts = append(authorParts, name)
}
if len(authorParts) >= 2 {
metadata.Authors = []string{strings.Join(authorParts, " ")}
} else if len(authorParts) == 1 {
metadata.Authors = []string{authorParts[0]}
}
if len(metadata.Authors) == 0 {
if author := extractPostBValue("Автор"); author != "" {
authors := regexp.MustCompile(`[,;]\s*`).Split(author, -1)
for i, a := range authors {
authors[i] = strings.TrimSpace(a)
}
metadata.Authors = authors
}
}
}
// extractNarrators извлекает чтецов
func (s *MetadataService) extractNarrators(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if narrator := extractPostBValue("Исполнитель"); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
return
}
narratorFields := []string{"Чтец", "Читает", "Диктор", "Narrator"}
for _, field := range narratorFields {
if narrator := extractPostBValue(field); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
break
}
}
}
// extractPublisher извлекает издательство
func (s *MetadataService) extractPublisher(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if publisher := extractPostBValue("Издательство"); publisher != "" {
publisher = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(publisher, "")
publisher = strings.TrimSpace(publisher)
if publisher != "" {
metadata.Publisher = &publisher
return
}
}
publisherFields := []string{"Выпущено", "Выпуск", "Издано", "Publisher"}
for _, field := range publisherFields {
if pub := extractPostBValue(field); pub != "" {
pub = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(pub, "")
pub = strings.TrimSpace(pub)
if pub != "" {
metadata.Publisher = &pub
break
}
}
}
}
// extractISBN извлекает ISBN
func (s *MetadataService) extractISBN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if isbn := extractPostBValue("ISBN"); isbn != "" {
metadata.ISBN = &isbn
return
}
isbnPatterns := []string{
`(?i)isbn[\s:-]*(\d{3}[-\s]?\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{13})`,
`(?i)isbn[\s:-]*(\d{10})`,
}
for _, pattern := range isbnPatterns {
if isbnMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(isbnMatch) >= 2 {
isbn := strings.ReplaceAll(isbnMatch[1], " ", "")
isbn = strings.ReplaceAll(isbn, "-", "")
if len(isbn) == 10 || len(isbn) == 13 {
metadata.ISBN = &isbn
break
}
}
}
}
// extractASIN извлекает ASIN
func (s *MetadataService) extractASIN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if asin := extractPostBValue("ASIN"); asin != "" {
metadata.ASIN = &asin
return
}
asinPatterns := []string{
`(?i)asin[\s:-]*([A-Z0-9]{10})`,
`(?i)amazon[\s]*asin[\s:-]*([A-Z0-9]{10})`,
}
for _, pattern := range asinPatterns {
if asinMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(asinMatch) >= 2 {
metadata.ASIN = &asinMatch[1]
break
}
}
}
// extractDescription извлекает описание
func (s *MetadataService) extractDescription(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
// Сначала пытаемся вытащить "богатое" описание до <span class="post-br"> или следующего label
if rich := s.extractRichField(decodedContent, "Описание"); rich != "" {
metadata.Description = rich
return
}
// Простое извлечение через post-b
if desc := extractPostBValue("Описание"); desc != "" {
metadata.Description = desc
return
}
// Альтернативные поля
descFields := []string{"О книге", "Аннотация", "Summary", "About"}
for _, field := range descFields {
if rich := s.extractRichField(decodedContent, field); rich != "" {
metadata.Description = rich
return
}
if desc := extractPostBValue(field); desc != "" {
metadata.Description = desc
return
}
}
}
// extractRichField извлекает текст после <span class="post-b">label</span> с учётом вариантов двоеточия
func (s *MetadataService) extractRichField(html string, label string) string {
// Варианты:
// 1) <span class="post-b">Описание</span>: TEXT ...
// 2) <span class="post-b">Описание:</span> TEXT ...
// Останавливаемся на <span class="post-br">, следующем <span class="post-b"> или конце текста
pattern := `(?is)<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>\s*` + regexp.QuoteMeta(label) + `\s*:?[\s\S]*?</span>\s*:?\s*(.+?)(?:<span[^>]*class=["'][^"']*post-br[^"']*["'][^>]*>|<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>|$)`
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
// Удаляем HTML теги
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
// Декодируем сущности и нормализуем пробелы
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвост вида ": 1." на конце (если вдруг остался из разметки)
text = regexp.MustCompile(`\s*:\s*\d+\.?$`).ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// extractSeries извлекает серию
func (s *MetadataService) extractSeries(extractPostBValue func(string) string, decodedContent string, torrent entities.Torrent, metadata *entities.AudioBookMetadata) {
// Ищем в post-b полях
if series := extractPostBValue("Серия"); series != "" {
metadata.Series = []string{series}
return
}
seriesFields := []string{"Цикл", "Сага", "Собрание сочинений", "Антология", "Series"}
for _, field := range seriesFields {
if series := extractPostBValue(field); series != "" {
metadata.Series = []string{series}
return
}
}
// Дополнительно: парсим упоминания в тексте/ссылках, например: "Цикл «Наследник»"
patterns := []string{
`(?is)<a[^>]*class=["'][^"']*postLink[^"']*["'][^>]*>\s*Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]\s*</a>`,
`(?i)Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Серия\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Сага\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
}
for _, p := range patterns {
if m := regexp.MustCompile(p).FindStringSubmatch(decodedContent); len(m) >= 2 {
name := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
if name != "" {
metadata.Series = []string{name}
return
}
}
}
}
// detectContentFlags определяет флаги контента
func (s *MetadataService) detectContentFlags(decodedContent string, metadata *entities.AudioBookMetadata) {
contentLower := strings.ToLower(decodedContent)
// Эксплицитность
explicitKeywords := []string{"18+", "эротик", "порно", "xxx", "adult", "explicit"}
for _, keyword := range explicitKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Explicit = true
break
}
}
// Сокращенность
abridgedKeywords := []string{"сокращен", "краткая", "abridged", "shortened"}
for _, keyword := range abridgedKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Abridged = true
break
}
}
}
// decodeHTMLEntities декодирует HTML-сущности (включая числовые)
func (s *MetadataService) decodeHTMLEntities(str string) string {
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&nbsp;": " ",
"&#39;": "'",
"&#34;": "\"",
"&laquo;": "«",
"&raquo;": "»",
"&ndash;": "",
"&mdash;": "—",
"&hellip;": "…",
}
result := str
for entity, replacement := range replacements {
result = strings.ReplaceAll(result, entity, replacement)
}
// Декодируем числовые сущности: десятичные и шестнадцатеричные
decRe := regexp.MustCompile(`&#(\d+);`)
result = decRe.ReplaceAllStringFunc(result, func(m string) string {
parts := decRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.Atoi(parts[1]); err == nil && code > 0 {
return string(rune(code))
}
return m
})
hexRe := regexp.MustCompile(`&#x([0-9a-fA-F]+);`)
result = hexRe.ReplaceAllStringFunc(result, func(m string) string {
parts := hexRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.ParseInt(parts[1], 16, 32); err == nil && code > 0 {
return string(rune(code))
}
return m
})
return result
}
// extractHTMLTitle ищет заголовок в разметке поста, затем в og:title и <title>
func (s *MetadataService) extractHTMLTitle(html string) string {
// Сначала пытаемся ограничиться телом первого поста
postBodyRe := regexp.MustCompile(`(?is)<div[^>]*class=["'][^"']*post_body[^"']*["'][^>]*>(.*?)</div>`)
scope := html
if m := postBodyRe.FindStringSubmatch(html); len(m) >= 2 {
scope = m[1]
}
// Ищем span с font-size: 24px (допускаем пробел между 24 и px)
spanRe := regexp.MustCompile(`(?is)<span[^>]*style=["'][^"']*font-size\s*:\s*24\s*px[^"']*["'][^>]*>\s*(.*?)\s*</span>`)
if m := spanRe.FindStringSubmatch(scope); len(m) >= 2 {
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: og:title
if m := regexp.MustCompile(`(?is)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(html); len(m) >= 2 {
text := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: <title>
if m := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`).FindStringSubmatch(html); len(m) >= 2 {
text := regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(m[1], " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
return ""
}
// extractPageTitle извлекает содержимое тега <title> без агрессивной нормализации
func (s *MetadataService) extractPageTitle(html string) string {
re := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвосты вида " :: RuTracker.org" / " | RuTracker.org" / " - RuTracker.org"
text = s.stripSiteSuffix(text)
return strings.TrimSpace(text)
}
// stripSiteSuffix убирает служебные суффиксы сайта в конце заголовка
func (s *MetadataService) stripSiteSuffix(t string) string {
if t == "" {
return t
}
// Удаляем любые повторяющиеся разделители перед RuTracker.org на конце
re := regexp.MustCompile(`(?i)\s*(?:\s*(?:\||::|:|—|-)\s*)+RuTracker\.?org.*$`)
return strings.TrimSpace(re.ReplaceAllString(t, ""))
}
// normalizeTitle чистит служебные хвосты и префиксы автора
func (s *MetadataService) normalizeTitle(t string) string {
if t == "" {
return t
}
// Убираем упоминание сайта и мусорные хвосты
t = regexp.MustCompile(`(?i)\s*[:|\-—]*\s*RuTracker\.?org.*$`).ReplaceAllString(t, "")
t = regexp.MustCompile(`\s*\[[^\]]*\]\s*$`).ReplaceAllString(t, "") // хвосты в [скобках]
t = regexp.MustCompile(`\s*\([^\)]*\)\s*$`).ReplaceAllString(t, "") // хвосты в (скобках)
// Если формат "Автор - Название" / "Автор — Название" — берем правую часть
if parts := regexp.MustCompile(`\s+[\-—]\s+`).Split(t, 2); len(parts) == 2 {
// Берём правую часть, если она не пустая
if strings.TrimSpace(parts[1]) != "" {
return strings.TrimSpace(parts[1])
}
}
return strings.TrimSpace(t)
}
// extractTags ищет ссылку на раздел форума (viewforum.php) и кладёт её текст в Tags одним элементом
func (s *MetadataService) extractTags(html string, metadata *entities.AudioBookMetadata) {
re := regexp.MustCompile(`(?is)<a[^>]+href=["'][^"']*viewforum\\.php\?f=\d+[^"']*["'][^>]*>\s*(.*?)\s*</a>`)
matches := re.FindAllStringSubmatch(html, -1)
best := ""
bestScore := -1
bestLen := 0
for _, m := range matches {
if len(m) < 2 {
continue
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем префикс в квадратных скобках, например [Аудио]
text = regexp.MustCompile(`^\[[^\]]+\]\s*`).ReplaceAllString(text, "")
if text == "" {
continue
}
lower := strings.ToLower(text)
score := 0
for _, kw := range []string{"фантаст", "фэнтез", "мистик", "ужас", "фанфик"} {
if strings.Contains(lower, kw) {
score++
}
}
if score > bestScore || (score == bestScore && len(text) > bestLen) {
best = text
bestScore = score
bestLen = len(text)
}
}
if best != "" {
metadata.Tags = []string{best}
}
}

View File

@@ -0,0 +1,42 @@
package services
import (
"testing"
"audio-catalyst/internal/domain/entities"
)
func TestExtractHTMLTitleAndSubtitle(t *testing.T) {
s := NewMetadataService()
html := `<html><head><title>Автор - Полный тайтл :: RuTracker.org</title></head>
<div class="post_body"><span style="font-size: 24px; line-height: normal;">Наследник. Книга 03</span></div></html>`
m, err := s.ParseTopicMetadata(html, entities.Torrent{Title: "Папка"})
if err != nil {
t.Fatal(err)
}
if m.Title != "Наследник. Книга 03" {
t.Errorf("title=%q", m.Title)
}
if m.Subtitle != "Автор - Полный тайтл" {
t.Errorf("subtitle=%q", m.Subtitle)
}
}
func TestExtractSeriesAndTags(t *testing.T) {
s := NewMetadataService()
h := `<div class="post_body">
<a href="viewforum.php?f=2387">[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики</a>
<a href="tracker.php?f=2387" class="postLink">Цикл «Наследник»</a>
<span class="post-b">Описание</span>: Текст
</div>`
m, err := s.ParseTopicMetadata(h, entities.Torrent{Title: "X"})
if err != nil {
t.Fatal(err)
}
if len(m.Series) == 0 || m.Series[0] != "Наследник" {
t.Errorf("series=%v", m.Series)
}
if len(m.Tags) == 0 || m.Tags[0] != "Российская фантастика, фэнтези, мистика, ужасы, фанфики" {
t.Errorf("tags=%v", m.Tags)
}
}