From e054f458bbc85b171dc3662d77ad9327ececf6a2 Mon Sep 17 00:00:00 2001 From: Anthony Axenov Date: Sat, 30 May 2026 09:24:42 +0800 Subject: [PATCH] wip2 --- .gitignore | 1 + AGENTS.md | 187 ++++++++++++++++++++++++++++++++++ Makefile | 20 +++- app/app.go | 24 ++++- app/checker/checker.go | 102 ++++++++++++++----- app/config/config.go | 7 +- app/config/config_test.go | 49 +++++++++ app/logger/logger.go | 20 ---- app/playlist/playlist.go | 13 +-- app/playlist/playlist_test.go | 63 ++++++++++++ app/tagfile/tagfile.go | 8 +- app/utils/utils.go | 9 +- app/utils/utils_test.go | 51 ++++++++++ cmd/check.go | 23 ++--- cmd/root.go | 6 ++ cmd/version.go | 2 +- main.go | 26 ++++- 17 files changed, 533 insertions(+), 78 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/config/config_test.go create mode 100644 app/playlist/playlist_test.go create mode 100644 app/utils/utils_test.go diff --git a/.gitignore b/.gitignore index e995921..c62a2a7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ output/ iptvc !/**/*.gitkeep +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f48052 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,187 @@ +# Контекст проекта: IPTV Checker (iptvc) + +> **Важно:** Этот файл создан автоматически на основе анализа кодовой базы. Используй его как справочный контекст при внесении изменений. + +--- + +## Обзор проекта + +**IPTV Checker (iptvc)** — консольная утилита для проверки доступности IPTV-плейлистов и каналов в форматах `m3u` / `m3u8`. + +- **Назначение:** Быстрая массовая проверка плейлистов из файлов, по URL или из INI-реестра с расчётом статистики (online/offline). +- **Экосистема:** Часть проекта [m3u.su](https://m3u.su). +- **Лицензия:** MIT (см. `LICENSE`). +- **Автор:** Антон Аксенов. + +### Основные технологии + +- **Язык:** Go 1.23.6+ +- **CLI-фреймворк:** [spf13/cobra](https://github.com/spf13/cobra) +- **Кеширование:** [Redis / KeyDB](https://github.com/redis/go-redis) (опционально, через `go-redis/v9`) +- **Конфигурация окружения:** [godotenv](https://github.com/joho/godotenv) + переменные окружения +- **Парсинг INI:** [gopkg.in/ini.v1](https://gopkg.in/ini.v1) + +### Архитектура + +Приложение построено по классической для Go CLI схеме: + +``` +cmd/ — команды Cobra (root, check) +app/ — бизнес-логика и инфраструктура + app.go — инициализация конфигурации, логгера, кеша; глобальные аргументы + checker/ — ядро проверки: подготовка списков, HTTP-опрос каналов, семафоры + playlist/ — модели Playlist/Channel/Group, парсинг m3u/m3u8 + inifile/ — чтение реестра плейлистов из INI + config/ — чтение конфигурации из env (.env) + cache/ — подключение к Redis/KeyDB + logger/ — настройка вывода логов + tagfile/ — работа с файлом тегов (channels.json) + utils/ — вспомогательные функции (MD5, Fetch, ExpandPath и др.) +main.go — точка входа: контекст с отменой по сигналу, делегация cmd.ExecuteContext +``` + +--- + +## Сборка и запуск + +### Локальная разработка + +```bash +# Быстрый запуск из исходников +go run . + +# Сборка бинаря +go build -o iptvc . + +# Показать справку +./iptvc help + +# Проверить плейлист из файла +./iptvc check -f mypls.m3u + +# Проверить плейлист по ссылке +./iptvc check -u http://m3u.su/XYZ + +# Проверить плейлисты из INI-файла +./iptvc check -i playlists.ini + +# Только JSON-результат, без логов +./iptvc check -f mypls.m3u -j -q +``` + +### Makefile + +| Команда | Описание | +|---------|----------| +| `make help` | Справка по целям | +| `make fmt` | `go fmt ./...` | +| `make vet` | `go vet ./...` | +| `make tidy` | `go mod tidy && go mod verify` | +| `make lint` | `fmt` + `vet` + сборка в `/dev/null` | +| `make clean` | Удаление скомпилированных бинарей и `bin/` | +| `make linux` | Сборка для Linux (`GOARCH=amd64` по умолчанию) | +| `make win` | Сборка для Windows | +| `make darwin` | Сборка для macOS | +| `make release` | Полный релиз: все ОС × amd64 + arm64, упаковка в zip | + +> **Примечание:** Переменная `GOARCH` переопределяется через окружение, например: `make linux GOARCH=arm64`. + +### Docker + +```bash +# Ручная сборка образа +docker build -t iptvc:latest . + +# Скрипт сборки и публикации (с версионированием через git-тег) +./build-docker-image.sh [TAG] +``` + +Dockerfile использует многоступенчатую сборку: +1. `golang:1.25-alpine` — компиляция бинаря (`CGO_ENABLED=0`, `trimpath`, `ldflags`) +2. `alpine:3.22.2` — финальный минимальный образ с непривилегированным пользователем. + +--- + +## Конфигурация + +Приложение читает переменные окружения (файл `.env` опционален). + +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `CACHE_ENABLED` | `false` | Включить подключение к кешу | +| `CACHE_HOST` | `localhost` | Хост Redis/KeyDB | +| `CACHE_PORT` | `6379` | Порт | +| `CACHE_USERNAME` | — | Пользователь | +| `CACHE_PASSWORD` | — | Пароль | +| `CACHE_DB` | `0` | Номер БД | +| `CACHE_TTL` | `1800` | TTL записей в секундах | + +> Файл `.env.example` содержит пример заполнения. + +--- + +## Ключевые файлы и пакеты + +| Путь | Назначение | +|------|------------| +| `main.go` | Точка входа: `context.WithCancel`, обработка `SIGINT`/`SIGTERM`, вызов `cmd.ExecuteContext` | +| `cmd/root.go` | Корневая команда Cobra, глобальный флаг `--verbose` | +| `cmd/check.go` | Команда `check`: флаги (`-f`, `-u`, `-i`, `-c`, `-t`, `-r`, `-j`, `-q`, `--repeat`, `--every`), цикл повторений, вызов `checker` | +| `app/app.go` | Глобальные объекты `Args`, `Cache`, `Config`; `Init()` и `Shutdown()` | +| `app/checker/checker.go` | **Ядро:** подготовка списков, скачивание плейлистов, HTTP-опрос каналов с семафором, динамический расчёт `timeout`/`routines`, кеширование результатов | +| `app/playlist/playlist.go` | Структуры `Playlist`, `Channel`, `Group`; парсинг m3u/m3u8 (атрибуты `#EXTINF`, `#EXTGRP`, заголовок `#EXTM3U`) | +| `app/config/config.go` | Чтение конфигурации из env с дефолтами | +| `app/inifile/inifile.go` | Чтение реестра плейлистов в формате INI | +| `app/cache/cache.go` | Инициализация клиента Redis/KeyDB | +| `app/tagfile/tagfile.go` | Загрузка и поиск тегов для каналов | +| `app/utils/utils.go` | Утилиты: `Fetch`, `ExpandPath`, `Md5str`, `ArrayUnique` | +| `app/utils/utils_test.go` | Тесты утилит | +| `app/config/config_test.go` | Тесты конфигурации | +| `app/playlist/playlist_test.go` | Тесты парсера плейлистов | +| `docs/json.md` | Документация по структуре JSON-вывода | +| `build-docker-image.sh` | Скрипт сборки и пуша Docker-образа с версией из git | +| `Dockerfile` | Многоступенчатая сборка контейнера | + +--- + +## Правила разработки + +### Стиль кода + +- Код оформлен в едином стиле с лидирующими комментариями-заголовками (автор, лицензия, ссылка на репозиторий). +- Используются экспортируемые имена с подробными комментариями на русском языке (доминирующий язык комментариев). +- Глобальные переменные приложения (`Args`, `Cache`, `Config`) расположены в `app/app.go`. + +### Тестирование + +- Модульные тесты присутствуют для пакетов `utils`, `config`, `playlist`. +- Для запуска: `go test ./...`. + +### Линтинг + +- В Makefile есть цель `lint`, объединяющая `go fmt`, `go vet` и проверочную сборку. +- Перед коммитом рекомендуется выполнять `make lint`. + +### Работа с зависимостями + +- Модули Go (`go.mod` / `go.sum`). +- Обновление/очистка: `make tidy`. + +### Соглашения по логике + +- **Динамические параметры проверки:** количество горутин и HTTP-таймаут рассчитываются в `calcParameters(count)` на основе числа каналов. Чем больше каналов — тем больше параллелизм, но таймаут сокращается. +- **Семафор:** количество одновременных проверок ограничено через буферизированный канал (`chSemaphores`). +- **User-Agent:** при проверке каналов используется фиксированный заголовок `Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer`. +- **TLS:** проверка выполняется с `InsecureSkipVerify: true`. +- **Кеш:** результаты проверки плейлистов сериализуются в JSON и сохраняются в Redis с TTL. + +--- + +## Коды возврата + +| Код | Значение | +|-----|----------| +| `0` | Успех | +| `1` | Общая ошибка | +| `2` | Команда `check` запущена без параметров `-f`, `-u` и `-c` (и нет INI по умолчанию) | +| `130` | Прерывание по сигналу (`SIGINT` / `SIGTERM`) | diff --git a/Makefile b/Makefile index 5cadd3b..88936b4 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,25 @@ release: clean @make darwin GOARCH=amd64 @make darwin GOARCH=arm64 +## fmt: Format Go source code +fmt: + @go fmt ./... + +## vet: Run go vet +vet: + @go vet ./... + +## tidy: Tidy and verify Go modules +tidy: + @go mod tidy + @go mod verify + +## lint: Run fmt, vet and build as basic linting +lint: fmt vet + @go build -o /dev/null . + @echo "Linting complete" + ## help: Show this message and exit help: Makefile @echo "Available recipes:" - @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' + @sed -n 's/^## //p' $< | column -t -s ':' diff --git a/app/app.go b/app/app.go index 0c1c8f7..9fcab65 100644 --- a/app/app.go +++ b/app/app.go @@ -10,11 +10,12 @@ import ( "axenov/iptv-checker/app/cache" "axenov/iptv-checker/app/config" "axenov/iptv-checker/app/logger" + "log" "github.com/redis/go-redis/v9" ) -const VERSION = "1.1.3" +var version = "dev" // Arguments описывает аргументы командной строки type Arguments struct { @@ -34,6 +35,18 @@ var ( Config *config.Config ) +// SetVersion устанавливает версию приложения +func SetVersion(v string) { + if v != "" { + version = v + } +} + +// Version возвращает версию приложения +func Version() string { + return version +} + // Init инициализирует конфигурацию и подключение к keydb func Init() { Config = config.Init() @@ -42,3 +55,12 @@ func Init() { Cache = cache.Init(&Config.Cache) } } + +// Shutdown корректно завершает работу приложения +func Shutdown() { + if Cache != nil { + if err := Cache.Close(); err != nil { + log.Printf("Error closing cache connection: %s", err) + } + } +} diff --git a/app/checker/checker.go b/app/checker/checker.go index 2070b7b..4607a85 100644 --- a/app/checker/checker.go +++ b/app/checker/checker.go @@ -28,13 +28,20 @@ import ( "time" ) -var ( +// Checker выполняет проверку плейлистов и каналов. +type Checker struct { tagBlocks []tagfile.TagBlock - ctx = context.Background() -) +} + +// NewChecker создаёт новый экземпляр Checker. +func NewChecker(tagsPath string) *Checker { + return &Checker{ + tagBlocks: tagfile.Init(tagsPath), + } +} // PrepareListsToCheck готовит список плейлистов для проверки -func PrepareListsToCheck(files []string, urls []string, codes []string) []playlist.Playlist { +func (c *Checker) PrepareListsToCheck(files []string, urls []string, codes []string) []playlist.Playlist { var lists []playlist.Playlist if len(files) > 0 { @@ -51,7 +58,11 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli if len(urls) > 0 { for _, url := range urls { - pls, _ := playlist.MakeFromUrl(url) + pls, err := playlist.MakeFromUrl(url) + if err != nil { + log.Printf("Warning: %s, skipping\n", err) + continue + } lists = append(lists, pls) } } @@ -74,7 +85,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli } } else { if app.Config.Cache.IsActive { - cachedLists := getCachedPlaylists() + cachedLists := c.getCachedPlaylists() for key := range ini.Lists { if _, ok := cachedLists[key]; ok { continue @@ -97,20 +108,30 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli } // getCachedPlaylists возвращает из кеша проверенные ранее плейлисты -func getCachedPlaylists() map[string]playlist.Playlist { +func (c *Checker) getCachedPlaylists() map[string]playlist.Playlist { result := make(map[string]playlist.Playlist) - keys := app.Cache.Keys(ctx, "*") - for _, key := range keys.Val() { - value := app.Cache.Get(ctx, key).Val() + ctx := context.Background() + iter := app.Cache.Scan(ctx, 0, "*", 100).Iterator() + for iter.Next(ctx) { + key := iter.Val() + value, err := app.Cache.Get(ctx, key).Result() + if err != nil { + continue + } var pls playlist.Playlist - _ = json.Unmarshal([]byte(value), &pls) + if err := json.Unmarshal([]byte(value), &pls); err != nil { + continue + } result[pls.Code] = pls } + if err := iter.Err(); err != nil { + log.Printf("Error scanning cache: %s", err) + } return result } // CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки -func CheckPlaylists(lists []playlist.Playlist) (int, int) { +func (c *Checker) CheckPlaylists(ctx context.Context, lists []playlist.Playlist) (int, int) { count := len(lists) if count == 0 { log.Println("There are no playlists to check") @@ -119,7 +140,6 @@ func CheckPlaylists(lists []playlist.Playlist) (int, int) { log.Printf("%d playlists will be checked\n", len(lists)) step, onlineCount, offlineCount := 0, 0, 0 - tagBlocks = tagfile.Init(app.Args.TagsPath) for idx := range lists { pls := lists[idx] @@ -146,7 +166,7 @@ func CheckPlaylists(lists []playlist.Playlist) (int, int) { if err != nil { log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err) offlineCount++ - cachePlaylist(pls) + c.cachePlaylist(pls) continue } @@ -156,15 +176,15 @@ func CheckPlaylists(lists []playlist.Playlist) (int, int) { pls = pls.Parse() log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels)) - pls = CheckChannels(pls) + pls = c.CheckChannels(ctx, pls) lists[idx] = pls - cachePlaylist(pls) + c.cachePlaylist(pls) } return onlineCount, offlineCount } -func cachePlaylist(pls playlist.Playlist) { +func (c *Checker) cachePlaylist(pls playlist.Playlist) { if !app.Config.Cache.IsActive { return } @@ -172,19 +192,27 @@ func cachePlaylist(pls playlist.Playlist) { jsonBytes, err := json.Marshal(pls) if err != nil { log.Printf("Error while saving playlist to cache: %s", err) + return } + key := pls.Code + if key == "" { + key = "raw:" + utils.Md5str(pls.Url) + } + + ctx := context.Background() ttl := time.Duration(app.Config.Cache.Ttl) * time.Second - written := app.Cache.Set(ctx, pls.Code, string(jsonBytes), ttl) + written := app.Cache.Set(ctx, key, string(jsonBytes), ttl) if written.Err() != nil { - log.Printf("Error while saving playlist to cache: %s", err) + log.Printf("Error while saving playlist to cache: %s", written.Err()) + return } log.Println("Cached sucessfully") } // CheckChannels проверяет каналы и возвращает их же с результатами проверки -func CheckChannels(pls playlist.Playlist) playlist.Playlist { +func (c *Checker) CheckChannels(ctx context.Context, pls playlist.Playlist) playlist.Playlist { type errorData struct { tvChannel playlist.Channel err error @@ -200,7 +228,13 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist { pls.OfflineCount = 0 timeout, routines := calcParameters(count) - httpClient := http.Client{Timeout: timeout} + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + httpClient := http.Client{ + Timeout: timeout, + Transport: tr, + } chSemaphores := make(chan struct{}, routines) chOnline := make(chan playlist.Channel, len(pls.Channels)) chOffline := make(chan playlist.Channel, len(pls.Channels)) @@ -211,16 +245,18 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist { for _, tvChannel := range pls.Channels { wg.Add(1) go func(tvChannel playlist.Channel) { - chSemaphores <- struct{}{} defer func() { + if r := recover(); r != nil { + log.Printf("Panic while checking channel '%s': %v", tvChannel.Title, r) + } <-chSemaphores wg.Done() }() + chSemaphores <- struct{}{} - tvChannel.Tags = getTagsForChannel(tvChannel) + tvChannel.Tags = c.getTagsForChannel(tvChannel) - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - req, err := http.NewRequest("GET", tvChannel.URL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", tvChannel.URL, nil) if err != nil { data := errorData{tvChannel: tvChannel, err: err} chError <- data @@ -241,7 +277,13 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist { tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest tvChannel.ContentType = resp.Header.Get("Content-Type") chunk := io.LimitReader(resp.Body, 512) // just for sure - bodyBytes, _ := io.ReadAll(chunk) + bodyBytes, err := io.ReadAll(chunk) + if err != nil { + _ = resp.Body.Close() + data := errorData{tvChannel: tvChannel, err: err} + chError <- data + return + } bodyString := string(bodyBytes) _ = resp.Body.Close() contentType := http.DetectContentType(bodyBytes) @@ -325,6 +367,10 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist { // calcParameters вычисляет оптимальное количество горутин и таймаут запроса func calcParameters(count int) (time.Duration, int) { + if count <= 0 { + return 10 * time.Second, 1 + } + routines := count if routines > 3000 { routines = 3000 @@ -360,10 +406,10 @@ func calcParameters(count int) (time.Duration, int) { } // getTagsForChannel ищет и возвращает теги для канала -func getTagsForChannel(tvChannel playlist.Channel) []string { +func (c *Checker) getTagsForChannel(tvChannel playlist.Channel) []string { var foundTags []string - for _, block := range tagBlocks { + for _, block := range c.tagBlocks { tags := block.GetTags(tvChannel) if tags != nil { foundTags = append(foundTags, tags...) diff --git a/app/config/config.go b/app/config/config.go index 9712a0a..6c55b73 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -7,15 +7,15 @@ package config import ( - "github.com/joho/godotenv" "os" "strconv" + + "github.com/joho/godotenv" ) // Config описывает конфигурацию type Config struct { - DebugMode bool - Cache CacheConfig + Cache CacheConfig } // CacheConfig описывает конфигурацию подключения к keydb @@ -34,7 +34,6 @@ type CacheConfig struct { func Init() *Config { _ = godotenv.Load(".env") return &Config{ - //DebugMode: readEnvBoolean("APP_DEBUG", false), Cache: CacheConfig{ IsEnabled: readEnvBoolean("CACHE_ENABLED", false), Host: readEnv("CACHE_HOST", "localhost"), diff --git a/app/config/config_test.go b/app/config/config_test.go new file mode 100644 index 0000000..9966630 --- /dev/null +++ b/app/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + "testing" +) + +func TestReadEnv(t *testing.T) { + _ = os.Setenv("TEST_VAR_IPTVC", "value123") + defer os.Unsetenv("TEST_VAR_IPTVC") + + got := readEnv("TEST_VAR_IPTVC", "default") + if got != "value123" { + t.Errorf("readEnv = %q, want %q", got, "value123") + } + + gotDefault := readEnv("TEST_VAR_MISSING", "default") + if gotDefault != "default" { + t.Errorf("readEnv default = %q, want %q", gotDefault, "default") + } +} + +func TestReadEnvBoolean(t *testing.T) { + _ = os.Setenv("TEST_BOOL", "true") + defer os.Unsetenv("TEST_BOOL") + + if !readEnvBoolean("TEST_BOOL", false) { + t.Error("readEnvBoolean(true) returned false") + } + + if readEnvBoolean("TEST_BOOL_MISSING", false) { + t.Error("readEnvBoolean(missing, false) returned true") + } +} + +func TestReadEnvInteger(t *testing.T) { + _ = os.Setenv("TEST_INT", "42") + defer os.Unsetenv("TEST_INT") + + got := readEnvInteger("TEST_INT", 0) + if got != 42 { + t.Errorf("readEnvInteger = %d, want 42", got) + } + + gotDefault := readEnvInteger("TEST_INT_MISSING", 10) + if gotDefault != 10 { + t.Errorf("readEnvInteger default = %d, want 10", gotDefault) + } +} diff --git a/app/logger/logger.go b/app/logger/logger.go index f4cba04..7dd656c 100644 --- a/app/logger/logger.go +++ b/app/logger/logger.go @@ -9,7 +9,6 @@ package logger import ( "io" "log" - "log/slog" "os" ) @@ -20,22 +19,3 @@ func Init(quiet bool) { log.SetOutput(io.Discard) } } - -// InitSlog инициализирует продвинутый логгер -// TODO пока непонятно что с этим делать -func InitSlog(quiet bool, debug bool) { - writer := os.Stdout - if quiet { - writer = nil - } - - level := slog.LevelInfo - if debug { - level = slog.LevelDebug - } - - options := slog.HandlerOptions{Level: level, AddSource: false} - handler := slog.NewTextHandler(writer, &options) - logger := slog.New(handler) - slog.SetDefault(logger) -} diff --git a/app/playlist/playlist.go b/app/playlist/playlist.go index 9abac7c..0b10847 100644 --- a/app/playlist/playlist.go +++ b/app/playlist/playlist.go @@ -54,8 +54,10 @@ type Playlist struct { CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp } -// tmpChannel хранит временные данные о канале, который обрабатывается в Parse -var tmpChannel = Channel{} +var ( + attrRegex = regexp.MustCompile(`(?U)([a-z-]+)="(.*)"`) + titleRegex = regexp.MustCompile(`['"]?\s*,\s*(.+)`) +) // MakeFromFile создаёт экземпляр плейлиста из файла func MakeFromFile(filepath string) (Playlist, error) { @@ -98,8 +100,7 @@ func MakeFromUrl(url string) (Playlist, error) { // parseAttributes парсит атрибуты тегов #EXT* func parseAttributes(line string) map[string]string { result := make(map[string]string) - regex := regexp.MustCompile(`(?U)([a-z-]+)="(.*)"`) - regexMatches := regex.FindAllStringSubmatch(line, -1) + regexMatches := attrRegex.FindAllStringSubmatch(line, -1) for _, match := range regexMatches { result[match[1]] = match[2] } @@ -110,8 +111,7 @@ func parseAttributes(line string) map[string]string { func parseTitle(line string) string { // сначала пытаемся по-доброму: в строке есть тег, могут быть атрибуты, // есть запятая-разделитель, после неё -- название канала (с запятыми или без) - regex := regexp.MustCompile(`['"]?\s*,\s*(.+)`) - regexMatches := regex.FindAllStringSubmatch(line, -1) + regexMatches := titleRegex.FindAllStringSubmatch(line, -1) if len(regexMatches) > 0 && len(regexMatches[0]) >= 2 { return strings.TrimSpace(regexMatches[0][1]) } @@ -159,6 +159,7 @@ func (pls *Playlist) ReadFromFs() error { // Parse разбирает плейлист func (pls *Playlist) Parse() Playlist { isChannel := false + tmpChannel := Channel{} pls.Attributes = make(map[string]string) pls.Channels = make(map[string]Channel) pls.Groups = make(map[string]Group) diff --git a/app/playlist/playlist_test.go b/app/playlist/playlist_test.go new file mode 100644 index 0000000..f826053 --- /dev/null +++ b/app/playlist/playlist_test.go @@ -0,0 +1,63 @@ +package playlist + +import ( + "testing" +) + +func TestParseAttributes(t *testing.T) { + line := `#EXTINF:-1 tvg-id="test" group-title="News",Channel Name` + attrs := parseAttributes(line) + if attrs["tvg-id"] != "test" { + t.Errorf("tvg-id = %q, want %q", attrs["tvg-id"], "test") + } + if attrs["group-title"] != "News" { + t.Errorf("group-title = %q, want %q", attrs["group-title"], "News") + } +} + +func TestParseTitle(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`#EXTINF:-1,Channel Name`, "Channel Name"}, + {`#EXTINF:-1 tvg-id="x",Another Channel`, "Another Channel"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + got := parseTitle(tt.input) + if got != tt.expected { + t.Errorf("parseTitle(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestPlaylistParse(t *testing.T) { + content := `#EXTM3U +#EXTINF:-1 tvg-id="1" group-title="News",First Channel +http://example.com/1 +#EXTINF:-1 tvg-id="2",Second Channel +http://example.com/2 +` + pls := Playlist{Content: content} + result := pls.Parse() + + if len(result.Channels) != 2 { + t.Errorf("channels count = %d, want 2", len(result.Channels)) + } + + if len(result.Groups) != 1 { + t.Errorf("groups count = %d, want 1", len(result.Groups)) + } + + for _, ch := range result.Channels { + if ch.URL == "" { + t.Error("channel URL is empty") + } + if ch.Title == "" { + t.Error("channel Title is empty") + } + } +} diff --git a/app/tagfile/tagfile.go b/app/tagfile/tagfile.go index d7c2c3a..8388773 100644 --- a/app/tagfile/tagfile.go +++ b/app/tagfile/tagfile.go @@ -75,8 +75,12 @@ func (block *TagBlock) GetTags(ch playlist.Channel) []string { // Init инициализирует объекты тегов из тегфайла func Init(path string) []TagBlock { - pathNormalized, _ := utils.ExpandPath(path) - _, err := os.Stat(pathNormalized) + pathNormalized, err := utils.ExpandPath(path) + if err != nil { + log.Println("Warning: all channels will be untagged due to error:", err) + return nil + } + _, err = os.Stat(pathNormalized) if err != nil { log.Println("Warning: all channels will be untagged due to error:", err) return nil diff --git a/app/utils/utils.go b/app/utils/utils.go index a36529f..0f29cd7 100644 --- a/app/utils/utils.go +++ b/app/utils/utils.go @@ -52,8 +52,13 @@ func Fetch(url string) ([]byte, error) { } req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer") - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - httpClient := http.Client{Timeout: 10 * time.Second} + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + httpClient := http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + } resp, err := httpClient.Do(req) if err != nil { return nil, err diff --git a/app/utils/utils_test.go b/app/utils/utils_test.go new file mode 100644 index 0000000..665cd35 --- /dev/null +++ b/app/utils/utils_test.go @@ -0,0 +1,51 @@ +package utils + +import ( + "testing" +) + +func TestMd5str(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "5d41402abc4b2a76b9719d911017c592"}, + {"", "d41d8cd98f00b204e9800998ecf8427e"}, + {"http://example.com/stream", "a1b2c3d4e5f6"}, // just length check + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := Md5str(tt.input) + if len(got) != 32 { + t.Errorf("Md5str(%q) length = %d, want 32", tt.input, len(got)) + } + }) + } +} + +func TestArrayUnique(t *testing.T) { + input := []string{"a", "b", "a", "c", "b"} + expected := []string{"a", "b", "c"} + got := ArrayUnique(input) + if len(got) != len(expected) { + t.Errorf("ArrayUnique length = %d, want %d", len(got), len(expected)) + } + seen := make(map[string]bool) + for _, v := range got { + if seen[v] { + t.Errorf("ArrayUnique returned duplicate: %s", v) + } + seen[v] = true + } +} + +func TestExpandPath(t *testing.T) { + got, err := ExpandPath("/tmp/test.txt") + if err != nil { + t.Fatalf("ExpandPath error: %v", err) + } + if got == "" { + t.Error("ExpandPath returned empty string") + } +} diff --git a/cmd/check.go b/cmd/check.go index ffaec3d..ea499b1 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -9,7 +9,6 @@ package cmd import ( "axenov/iptv-checker/app" "axenov/iptv-checker/app/checker" - "axenov/iptv-checker/app/playlist" "encoding/json" "fmt" "log" @@ -44,18 +43,14 @@ var checkCmd = &cobra.Command{ ) } - var lists []playlist.Playlist - if len(files) == 0 && len(urls) == 0 && len(codes) == 0 { - lists = checker.PrepareListsToCheck(files, urls, codes) - } else { - if currentIteration == 1 { - lists = checker.PrepareListsToCheck(files, urls, codes) - } - } + ch := checker.NewChecker(app.Args.TagsPath) + + lists := ch.PrepareListsToCheck(files, urls, codes) if len(lists) > 0 { startTime := time.Now() - onlineCount, offlineCount := checker.CheckPlaylists(lists) + ctx := cmd.Context() + onlineCount, offlineCount := ch.CheckPlaylists(ctx, lists) log.Printf( "Done! count=%d online=%d offline=%d elapsedTime=%.2fs\n", @@ -66,8 +61,12 @@ var checkCmd = &cobra.Command{ ) if app.Args.NeedJson { - marshal, _ := json.Marshal(lists) - fmt.Println(string(marshal)) + marshal, err := json.Marshal(lists) + if err != nil { + log.Printf("Error marshaling results: %s", err) + } else { + fmt.Println(string(marshal)) + } } } else { log.Println("There are no playlists to check") diff --git a/cmd/root.go b/cmd/root.go index bf6a35f..3158b86 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ package cmd import ( "axenov/iptv-checker/app" + "context" "os" "github.com/spf13/cobra" @@ -33,6 +34,11 @@ func Execute() { } } +// ExecuteContext runs the root command with the given context. +func ExecuteContext(ctx context.Context) error { + return rootCmd.ExecuteContext(ctx) +} + func init() { rootCmd.PersistentFlags().BoolVarP(&app.Args.Verbose, "verbose", "v", false, "enable additional output") } diff --git a/cmd/version.go b/cmd/version.go index af46917..78a3a03 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -18,7 +18,7 @@ var versionCmd = &cobra.Command{ Use: "version", Short: "Show version", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("iptvc v" + app.VERSION) + fmt.Println("iptvc v" + app.Version()) }, } diff --git a/main.go b/main.go index 40593f4..5bf1455 100644 --- a/main.go +++ b/main.go @@ -7,9 +7,33 @@ package main import ( + "axenov/iptv-checker/app" "axenov/iptv-checker/cmd" + "context" + "os" + "os/signal" + "syscall" ) +var version string + func main() { - cmd.Execute() + app.SetVersion(version) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + app.Shutdown() + os.Exit(130) + }() + + if err := cmd.ExecuteContext(ctx); err != nil { + os.Exit(1) + } + app.Shutdown() }