From d15d4f47b6012d0d98b3d6d0a3fa015d4a2d0b17 Mon Sep 17 00:00:00 2001 From: AnthonyAxenov Date: Thu, 1 May 2025 00:46:24 +0800 Subject: [PATCH] Initial commit --- .env.example | 10 + .gitea/workflows/release-tag.yml | 28 +++ .gitignore | 13 ++ LICENSE | 0 Makefile | 46 +++++ README.md | 154 ++++++++++++++ app/app.go | 38 ++++ app/cache/cache.go | 36 ++++ app/checker/checker.go | 336 +++++++++++++++++++++++++++++++ app/config/config.go | 82 ++++++++ app/inifile/inifile.go | 99 +++++++++ app/logger/logger.go | 41 ++++ app/playlist/playlist.go | 216 ++++++++++++++++++++ app/tagfile/tagfile.go | 87 ++++++++ app/utils/utils.go | 76 +++++++ cmd/check.go | 49 +++++ cmd/root.go | 39 ++++ cmd/version.go | 27 +++ docs/json.md | 114 +++++++++++ go.mod | 18 ++ go.sum | 32 +++ main.go | 15 ++ 22 files changed, 1556 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/release-tag.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/app.go create mode 100644 app/cache/cache.go create mode 100644 app/checker/checker.go create mode 100644 app/config/config.go create mode 100644 app/inifile/inifile.go create mode 100644 app/logger/logger.go create mode 100644 app/playlist/playlist.go create mode 100644 app/tagfile/tagfile.go create mode 100644 app/utils/utils.go create mode 100644 cmd/check.go create mode 100644 cmd/root.go create mode 100644 cmd/version.go create mode 100644 docs/json.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d88c416 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_DEBUG=false + +HTTP_HOST=0.0.0.0 +HTTP_PORT=8031 + +REDIS_HOST= +REDIS_PORT= +REDIS_USERNAME= +REDIS_PASSWORD= +REDIS_DB= diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml new file mode 100644 index 0000000..3bdf5e6 --- /dev/null +++ b/.gitea/workflows/release-tag.yml @@ -0,0 +1,28 @@ +name: release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + name: Checkout + with: + fetch-depth: 0 + - name: Setup go + uses: https://github.com/actions/setup-go@v4 + with: + go-version: '>=1.23.6' + - name: Compile + run: make release + - name: Make release + id: use-go-action + uses: https://gitea.com/actions/release-action@main + with: + files: |- + bin/*.zip + api_key: '${{secrets.RELEASE_TOKEN}}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..962f4f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea/ +.vscode/ +bin/ +output/ + +.env +*.bak +*.m3u +*.m3u8 +*.json +*.ini + +!/**/*.gitkeep diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f32f4e --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.DEFAULT_GOAL=help + +BINARY_NAME=iptvc +ARCH=amd64 + +LINUX_PATH="bin/linux_${ARCH}" +WINDOWS_PATH="bin/windows_${ARCH}" +DARWIN_PATH="bin/darwin_${ARCH}" + +LINUX_FILE="${LINUX_PATH}/${BINARY_NAME}" +WINDOWS_FILE="${WINDOWS_PATH}/${BINARY_NAME}.exe" +DARWIN_FILE="${DARWIN_PATH}/${BINARY_NAME}" + +## clean: Remove all compiled binaries +clean: + @go clean + @rm -rf bin/ + +## linux: Build new binaries for linux (x64) +linux: + @rm -rf ${LINUX_PATH} + @GOARCH=${ARCH} GOOS=linux go build -o ${LINUX_FILE} . && echo "Compiled: ${LINUX_FILE}" + +## win: Build new binaries for windows (x64) +win: + @rm -rf ${WINDOWS_PATH} + @GOARCH=${ARCH} GOOS=windows go build -o ${WINDOWS_FILE} . && echo "Compiled: ${WINDOWS_FILE}" + +## darwin: Build new binaries for darwin (x64) +darwin: + @rm -rf ${DARWIN_PATH} + @GOARCH=${ARCH} GOOS=darwin go build -o ${DARWIN_FILE} . && echo "Compiled: ${DARWIN_FILE}" + +## all: Build new binaries for linux, windows and darwin (x64) +all: clean linux win darwin + +## release: Build all binaries and zip them +release: clean darwin linux win + @zip -j ${LINUX_PATH}.zip ${LINUX_FILE} + @zip -j ${DARWIN_PATH}.zip ${DARWIN_FILE} + @zip -j ${WINDOWS_PATH}.zip ${WINDOWS_FILE} + +## help: Show this message and exit +help: Makefile + @echo "Choose a command run:" + @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b97fc2 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# IPTV Checker (iptvc) + +Консольная программа для проверки IPTV-плейлистов в формате m3u или m3u8. + +> [!IMPORTANT] +> Проект находится на ранней стадии разработки. +> Реализован минимально необходимый функционал. +> Возможны ошибки, неточности и обратно несовместимые изменения. + +Для дополнительной документации можно обращаться в директорию [docs](./docs). + +## Установка + +Достаточно скачать и распаковать архив с подходящим исполняемым файлом [со страницы релизов](https://git.axenov.dev/IPTV/iptvc/releases): + +| ОС | Архив | Платформа | +|---------|----------------------|-----------| +| Linux | `linux_amd64.zip` | x64 | +| MacOS | `darwin_amd64.zip` | x64 | +| Windows | `windows_amd64.zip` | x64 | + +## Компиляция + +Для сборки потребуется golang v1.23.6 и выше. +На версиях ниже не проверялось. + +1. Склонировать репозиторий +2. Находясь в корне репозитория, следует выполнить `make` или `make help` для получения справки. +3. Другой способ -- выполнить `go run .` для быстрого запуска. + +## Быстрый старт + +Открыть терминал в директории, куда распакован исполняемый файл `iptvc`. + +Выполнить `./iptvc help` для получения краткой справки. + +Если был клонирован репозиторий, то вместо `./iptvc` можно запустить `go run .` + +Ниже рассмотрены простые примеры использования программы для проверки плейлистов. + +### Проверка файла плейлиста + +1. Скачать любой файл плейлиста, сохранив его в файл с именем, например, `mypls.m3u` +2. Выполнить команду `./iptvc check -f mypls.m3u` + +Можно указывать множество разных файлов (каждый с `-f`) и комбинировать с другими аргументами. + +### Проверка плейлиста по ссылке + +1. Найти прямую ссылку на плейлист в интернете, например, `http://m3u.su/XYZ` +2. Выполнить команду `./iptvc check -u http://m3u.su/XYZ` + +Можно указывать множество разных ссылок (каждый с `-u`) и комбинировать с другими аргументами. + +### Проверка плейлиста из ini-списка + +Подробности об ini-файле и его формате можно прочесть здесь: https://git.axenov.dev/IPTV/playlists + +1. Скачать файл [`playlists.ini`](https://git.axenov.dev/IPTV/playlists/raw/branch/master/playlists.ini) или создать локальный файл в аналогичном формате (например, `./test.ini`) +2. Выполнить команду `./iptvc check` или `./iptvc check -i playlist.ini` для проверки всех плейлистов из файла `./playlists.ini` +3. Выполнить команду `./iptvc check -i test.ini -c ABC`, чтобы проверить только плейлист с кодом `ABC` из файла `./test.ini` + +Аргумент `-i` можно указывать только однажды, но его можно комбинировать с `-f` и `-u`. + +### Другие возможности команды `check` + +* `--json|-j` -- вывести результаты проверки в формате JSON +* `--quiet|-q` -- полностью подавить вывод лога (включая отладочную информацию) +* `--verbose|-v` -- добавить в лог более подробную отладочную информацию (значительно увеличит количество строк!) +* `--tags|-t` -- файл с перечислением тегов (подробности см. [здесь](https://git.axenov.dev/IPTV/playlists#файл-channelsjson)) + +Например, можно получить только json с результатами, передать его в `jq` и, отфильтровав результат, вывести названия оффлайн каналов: + +``` +./iptvc check ... -j -q | jq '.[].channels[] | select(.isOnline == false).title' +``` + +> [!NOTE] +> Набери `./iptvc help` для получения помощи. + +## Результаты проверки + +Программа логирует процесс и результаты своей работы. +Рассмотрим лог на примере. + +В рабочей директории лежит файл `playlists.ini`, в котором минимально описано два плейлиста: + +```ini +[first] +pls='https://example.com/list1.m3u' +[second] +pls='https://example.com/list2.m3u' +``` + +Программа запущена следующим образом: `./iptvc check -c first` (чтобы проверить только плейлист с кодом `first` из ini-файла). + +Вывод будет примерно таким: + +``` +2025/01/02 12:34:00 Loading playlists from ini-file: /home/user/playlists.ini +2025/01/02 12:34:00 Loaded 2 playlists +2025/01/02 12:34:00 [001/001] Playlist [first] +2025/01/02 12:34:00 Fetching... (https://example.com/list1.m3u) +2025/01/02 12:34:00 Parsing content... +2025/01/02 12:34:00 Parsed, checking channels (114)... +2025/01/02 12:34:00 Check parameters calculated: count=114 timeout=8.00s routines=12 +2025/01/02 12:34:12 Checked successfully! online=101 onlinePercent=88.60% offline=13 offlinePercent=11.40% elapsedTime=12.39s +``` + +Разберём построчно: +1. Загрузка ini-файла +2. Файл загружен, в нём 2 плейлиста +3. Начало обработки плейлиста `first` +4. Скачивание плейлиста `first` по ссылке `https://example.com/list1.m3u` в оперативную память +5. Плейлист скачан, начало разбора плейлиста +6. Плейлист разобран, начало проверки каналов (114 штук) +7. Определены параметры проверки (подробности ниже) +8. (спустя время) Проверка успешно завершена: онлайн каналов 101 (88.60% от всех), оффлайн каналов 13 (11.40% от всех), затрачено 12 секунд. + +### Параметры проверки + +Выше в п.7 видно некоторые служебные данные: +* `timeout` -- таймаут каждого запроса в секундах (макс. время ожидания ответа канала); +* `routines` -- количество одновременных проверок. + +Эти параметры рассчитываются динамически для каждого плейлиста в отдельности, исходя из количества каналов в каждом (`count`). +См. [app/checker/checker.go](app/checker/checker.go) для подробностей. + +Идея в том, чтобы найти баланс между скоростью проверки и качеством: +* чем ниже коэффициент `k`, тем больше горутин, меньше таймаут, быстрее проверка, хуже результаты; +* чем выше коэффициент `k`, тем меньше горутин, больше таймаут, медленнее проверка, лучше результаты. + +На скорость проверки влияют: +* количество горутин (чем выше, тем больше каналов проверяется параллельно); +* таймаут (тем ниже, тем быстрее закончится проверка канала); +* количество каналов (чем больше, тем дольше общий процесс); +* скорость ответа сервера (чем выше, тем хуже) и количество данных, которые он отдаёт (чем больше, тем хуже). + +На качество проверки влияет таймаут: +* чем выше, тем выше вероятность получить успешный ответ от сервера, транслирующего поток; +* чем ниже, тем выше вероятность не дождаться успешного ответа и засчитать канал нерабочим. + +> [!NOTE] +> Логика балансирования будет уточняться и корректироваться по мере развития проекта, исходя из реального применения. + +### Коды возврата + +* 0 -- успех +* 1 -- общая ошибка, см. вывод +* 2 -- команде `check` не переданы параметры `--file`, `--url` и `--code` + +## Лицензия + +ПО распространяется на условиях [лицензии MIT](LICENSE). diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..9fdca57 --- /dev/null +++ b/app/app.go @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package app + +import ( + "axenov/iptv-checker/app/config" + "github.com/redis/go-redis/v9" +) + +const VERSION = "0.1.0" + +// Arguments описывает аргументы командной строки +type Arguments struct { + IniPath string + TagsPath string + RandomCount uint + NeedJson bool + NeedQuiet bool + Verbose bool +} + +var ( + Args Arguments + Redis *redis.Client + Config *config.Config + //TagBlocks []tagfile.TagBlock +) + +// Init инициализирует глобальные переменные +func Init() { + Config = config.Init() + //logger.Init(Args.NeedQuiet) + //Redis = cache.Init(Config.Redis) +} diff --git a/app/cache/cache.go b/app/cache/cache.go new file mode 100644 index 0000000..0b9f931 --- /dev/null +++ b/app/cache/cache.go @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package cache + +import ( + "axenov/iptv-checker/app/config" + "context" + "fmt" + "github.com/redis/go-redis/v9" + "log" + "strconv" +) + +func Init(cfg config.RedisConfig) *redis.Client { + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", cfg.Host, strconv.Itoa(int(cfg.Port))), + DB: int(cfg.Db), + PoolSize: 1000, + ReadTimeout: -1, + WriteTimeout: -1, + }) + client := rdb.Conn() + + var ctx context.Context + if client.Ping(ctx).Err() != nil { + log.Println("Error while connecting to Redis", cfg.Host, cfg.Port, cfg.Db) + } else { + log.Println("Connected to Redis", cfg.Host, cfg.Port, cfg.Db) + } + + return rdb +} diff --git a/app/checker/checker.go b/app/checker/checker.go new file mode 100644 index 0000000..f3cb063 --- /dev/null +++ b/app/checker/checker.go @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package checker + +import ( + "axenov/iptv-checker/app" + "axenov/iptv-checker/app/inifile" + "axenov/iptv-checker/app/playlist" + "axenov/iptv-checker/app/tagfile" + "axenov/iptv-checker/app/utils" + "encoding/json" + "fmt" + "io" + "log" + "maps" + "math/rand" + "net/http" + "os" + "runtime" + "slices" + "strings" + "sync" + "time" +) + +var tagBlocks []tagfile.TagBlock + +// PrepareListsToCheck готовит список плейлистов для проверки +func PrepareListsToCheck(files []string, urls []string, codes []string) []playlist.Playlist { + var lists []playlist.Playlist + + if len(files) > 0 { + for _, filepath := range files { + pls, err := playlist.MakeFromFile(filepath) + if err != nil { + log.Printf("Warning: %s, skipping\n", err) + continue + } + + lists = append(lists, pls) + } + } + + if len(urls) > 0 { + for _, url := range urls { + pls, _ := playlist.MakeFromUrl(url) + lists = append(lists, pls) + } + } + + ini, err := inifile.Init(app.Args.IniPath) + if err != nil { + log.Printf("Warning: %s, all --code flags will be ignored\n", err) + return lists + } + + if len(codes) > 0 { + for _, plsCode := range codes { + list := ini.Lists[plsCode] + if list.Url == "" { + log.Printf("Warning: playlist [%s] not found in ini-file, skipping\n", plsCode) + continue + } + lists = append(lists, list) + } + } else { + lists = slices.Collect(maps.Values(ini.Lists)) + if int(app.Args.RandomCount) > 0 && int(app.Args.RandomCount) <= len(lists) { + rand.Shuffle(len(lists), func(i int, j int) { lists[i], lists[j] = lists[j], lists[i] }) + lists = lists[:app.Args.RandomCount] + } + } + + return lists +} + +// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки +func CheckPlaylists(lists []playlist.Playlist) { + step := 0 + count := len(lists) + tagBlocks = tagfile.Init(app.Args.TagsPath) + + if count == 0 { + log.Println("There are no playlists to check") + os.Exit(0) + } + + for idx := range lists { + pls := lists[idx] + step++ + + var err error + if pls.Source == "-f" { + // direct m3u path + log.Printf("[%.3d/%.3d] Playlist from filesystem\n", step, count) + log.Printf("Reading file... (%s)\n", pls.Url) + err = pls.ReadFromFs() + } else if pls.Source == "-u" { + // direct m3u url + log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Url) + log.Printf("Fetching... (%s)\n", pls.Url) + err = pls.Download() + } else { + // from ini + log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Code) + log.Printf("Fetching... (%s)\n", pls.Url) + err = pls.Download() + } + + if err != nil { + log.Printf("Cannot read playlist [%s]: %s", pls.Url, err) + continue + } + + log.Println("Parsing content...") + pls.IsOnline = true + pls = pls.Parse() + + log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels)) + pls = CheckChannels(pls) + + lists[idx] = pls + } + + if app.Args.NeedJson { + marshal, _ := json.Marshal(lists) + fmt.Println(string(marshal)) + } +} + +// CheckChannels проверяет каналы и возвращает их же с результатами проверки +func CheckChannels(pls playlist.Playlist) playlist.Playlist { + type errorData struct { + tvChannel playlist.Channel + err error + } + + count := len(pls.Channels) + timeout, routines := calcParameters(count) + httpClient := http.Client{Timeout: timeout} + chSemaphores := make(chan struct{}, routines) + chOnline := make(chan playlist.Channel, len(pls.Channels)) + chOffline := make(chan playlist.Channel, len(pls.Channels)) + chError := make(chan errorData, len(pls.Channels)) + var wg sync.WaitGroup + + startTime := time.Now() + for _, tvChannel := range pls.Channels { + wg.Add(1) + go func(tvChannel playlist.Channel) { + chSemaphores <- struct{}{} + defer func() { + <-chSemaphores + wg.Done() + }() + + tvChannel.Tags = getTagsForChannel(tvChannel) + + req, err := http.NewRequest("GET", tvChannel.URL, nil) + tvChannel.CheckedAt = time.Now().Unix() + if err != nil { + data := errorData{tvChannel: tvChannel, err: err} + chError <- data + return + } + + //TODO user-agent + req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer") + resp, err := httpClient.Do(req) + if err != nil { + data := errorData{tvChannel: tvChannel, err: err} + chError <- data + return + } + + tvChannel.Status = resp.StatusCode + tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest + tvChannel.ContentType = resp.Header.Get("Content-Type") + bodyBytes, _ := io.ReadAll(resp.Body) + bodyString := string(bodyBytes) + resp.Body.Close() + contentType := http.DetectContentType(bodyBytes) + + isContentBinary := strings.Contains(contentType, "octet-stream") || + strings.Contains(contentType, "video/") + + isContentCorrect := isContentBinary || + strings.Contains(bodyString, "#EXTM3U") || + strings.Contains(bodyString, "= http.StatusBadRequest || !isContentCorrect { + tvChannel.Error = bodyString + chOffline <- tvChannel + return + } + + if isContentBinary { + tvChannel.Content = "binary" + } else { + tvChannel.Content = bodyString + } + + chOnline <- tvChannel + return + }(tvChannel) + } + + for idx := 1; idx <= count; idx++ { + select { + case tvChannel := <-chOnline: + tvChannel.IsOnline = true + pls.OnlineCount++ + pls.Channels[tvChannel.Id] = tvChannel + if app.Args.Verbose { + log.Printf("[%.3d/%.3d] ONLINE '%s'\n", idx, count, tvChannel.Title) + log.Printf("> Id: %s\n", tvChannel.Id) + log.Printf("> Tags: %s\n", strings.Join(tvChannel.Tags, ",")) + log.Printf("> MimeType: %s\n", tvChannel.ContentType) + } + case tvChannel := <-chOffline: + pls.OfflineCount++ + pls.Channels[tvChannel.Id] = tvChannel + if app.Args.Verbose { + log.Printf("[%.3d/%.3d] OFFLINE '%s'\n", idx, count, tvChannel.Title) + log.Printf("> Id: %s\n", tvChannel.Id) + log.Printf("> Tags: %s\n", strings.Join(tvChannel.Tags, ",")) + log.Printf("> Status: %d\n", tvChannel.Status) + } + case data := <-chError: + pls.OfflineCount++ + pls.Channels[data.tvChannel.Id] = data.tvChannel + if app.Args.Verbose { + log.Printf("[%.3d/%.3d] ERROR '%s'\n", idx, count, data.tvChannel.Title) + log.Printf("> Id: %s\n", data.tvChannel.Id) + log.Printf("> Tags: %s\n", strings.Join(data.tvChannel.Tags, ",")) + log.Printf("> Error: %s\n", data.err) + } + } + } + + wg.Wait() + close(chOnline) + close(chOffline) + close(chError) + pls.CheckedAt = time.Now().Unix() + + log.Printf( + "Checked successfully! online=%d onlinePercent=%.2f%% offline=%d offlinePercent=%.2f%% elapsedTime=%.2fs", + pls.OnlineCount, + float64(pls.OnlineCount)/float64(len(pls.Channels))*100, + pls.OfflineCount, + float64(pls.OfflineCount)/float64(len(pls.Channels))*100, + time.Since(startTime).Seconds(), + ) + + return pls +} + +// calcParameters вычисляет оптимальное количество горутин и таймаут запроса +func calcParameters(count int) (time.Duration, int) { + // коэффициент нагрузки + var k float32 + // чем ниже, тем больше горутин, меньше таймаут, быстрее проверка, хуже результаты + // чем выше, тем меньше горутин, больше таймаут, медленнее проверка, лучше результаты + + switch true { + case count >= 4000: + k = 5 + case count >= 3000: + k = 4.5 + case count >= 2500: + k = 4 + case count >= 2000: + k = 3.5 + case count >= 1500: + k = 3 + case count >= 1000: + k = 2.5 + case count >= 500: + k = 2 + case count >= 100: + k = 1.5 + default: + k = 1 + } + + routines := int(float32(count) / k / float32(runtime.NumCPU())) + if routines > 500 { + routines = 500 + } + if routines < 1 { + routines = 1 + } + + timeout := 10/k + 2 + if timeout > 10 { + timeout = 10 + } + if timeout < 1 { + timeout = 1 + } + + duration := time.Duration(timeout) * time.Second + log.Printf( + "Check parameters calculated: count=%d timeout=%.2fs routines=%d\n", + count, + duration.Seconds(), + routines, + ) + + return duration, routines +} + +// getTagsForChannel ищет и возвращает теги для канала +func getTagsForChannel(tvChannel playlist.Channel) []string { + var foundTags []string + + for _, block := range tagBlocks { + tags := block.GetTags(tvChannel) + if tags != nil { + foundTags = append(foundTags, tags...) + } + } + + if len(foundTags) == 0 { + foundTags = append(foundTags, "untagged") + } else if len(foundTags) > 0 { + foundTags = utils.ArrayUnique(foundTags) + } + + return foundTags +} diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000..ed78801 --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package config + +import ( + "os" + "strconv" +) + +// Config описывает конфигурацию +type Config struct { + DebugMode bool + Redis RedisConfig + Http HttpConfig +} + +// RedisConfig описывает конфигурацию подключения к Redis +type RedisConfig struct { + Host string + Port uint + Username string + Password string + Db uint +} + +// HttpConfig описывает конфигурацию веб-сервера +type HttpConfig struct { + Host string + Port uint +} + +// Init инициализирует объект конфигурации из переменных окружения +func Init() *Config { + return &Config{ + DebugMode: readEnvBoolean("APP_DEBUG", false), + Redis: RedisConfig{ + Host: readEnv("REDIS_HOST", ""), + Port: readEnvInteger("REDIS_PORT", 6379), + Username: readEnv("REDIS_USERNAME", ""), + Password: readEnv("REDIS_PASSWORD", ""), + Db: readEnvInteger("REDIS_DB", 0), + }, + Http: HttpConfig{ + Host: readEnv("HTTP_HOST", "0.0.0.0"), + Port: readEnvInteger("HTTP_PORT", 1380), + }, + } +} + +// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию +func readEnv(key string, defaultValue string) string { + value, exists := os.LookupEnv(key) + if exists { + return value + } + + return defaultValue +} + +// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию +func readEnvBoolean(name string, defaultValue bool) bool { + valStr := readEnv(name, "") + val, err := strconv.ParseBool(valStr) + if err == nil { + return val + } + return defaultValue +} + +// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию +func readEnvInteger(name string, defaultValue uint) uint { + valueStr := readEnv(name, "") + value, err := strconv.Atoi(valueStr) + if err == nil { + return uint(value) + } + return defaultValue +} diff --git a/app/inifile/inifile.go b/app/inifile/inifile.go new file mode 100644 index 0000000..cbf5d22 --- /dev/null +++ b/app/inifile/inifile.go @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package inifile + +import ( + "axenov/iptv-checker/app/playlist" + "axenov/iptv-checker/app/utils" + "gopkg.in/ini.v1" + "log" + "os" + "strings" +) + +// IniFile описывает ini-файл c плейлистами +type IniFile struct { + File *ini.File + Lists map[string]playlist.Playlist +} + +// Init загружает данные из ini-файла +func Init(path string) (IniFile, error) { + ini.DefaultHeader = false + + pathNormalized, err := utils.ExpandPath(path) + if err != nil { + return IniFile{}, err + } + + _, err = os.Stat(pathNormalized) + if err != nil { + return IniFile{}, err + } + + iniFile, err := ini.Load(pathNormalized) + if err != nil { + return IniFile{}, err + } + + lists := make(map[string]playlist.Playlist) + + log.Println("Loading playlists from ini-file:", pathNormalized) + for _, section := range iniFile.Sections() { + if section.Name() == ini.DefaultSection { //TODO выкосить костыль + continue + } + + name := getName(section) + description := getValue("desc", section) + source := getValue("src", section) + url := getValue("pls", section) + + if url == "" { + log.Printf("Warning: playlist [%s] has incorrect 'pls', skipping", section.Name()) + continue + } + + lists[section.Name()] = playlist.Playlist{ + Code: section.Name(), + Name: name, + Description: description, + Url: section.KeysHash()["pls"], + Source: source, + } + } + log.Printf("Loaded %d playlists\n", len(lists)) + + return IniFile{ + File: iniFile, + Lists: lists, + }, nil +} + +// getValue возвращает значение по ключу в секции +func getValue(key string, section *ini.Section) string { + if _, ok := section.KeysHash()[key]; !ok { + return "" + } + + value := strings.Trim(section.KeysHash()[key], "\n\t ") + if value == "" { + return "" + } + + return value +} + +// getName возвращает имя плейлиста по секции +func getName(section *ini.Section) string { + name := getValue("name", section) + if name == "" { + return "Playlist #" + section.Name() + } + + return name +} diff --git a/app/logger/logger.go b/app/logger/logger.go new file mode 100644 index 0000000..f4cba04 --- /dev/null +++ b/app/logger/logger.go @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package logger + +import ( + "io" + "log" + "log/slog" + "os" +) + +// Init инициализирует стандартный логгер +func Init(quiet bool) { + log.SetOutput(os.Stdout) + if quiet { + 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 new file mode 100644 index 0000000..d9e3eef --- /dev/null +++ b/app/playlist/playlist.go @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package playlist + +import ( + "axenov/iptv-checker/app/utils" + "errors" + "os" + "regexp" + "strings" +) + +// Group - структура для хранения информации о группе каналов +type Group struct { + Id string `json:"id"` // Хэш MD5 от названия группы + Name string `json:"name"` // Название группы (тег #EXTGRP или атрибут group-title тега #EXTINF) + Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTGRP +} + +// Channel - структура для хранения информации о канале и статусе его проверки +type Channel struct { + Id string `json:"id"` // Хэш MD5 от ссылки на канал + Title string `json:"title"` // Название канала + URL string `json:"url"` // Ссылка на канал + GroupId string `json:"groupId"` // Хэш MD5 от названия группы канала (тег #EXTGRP или атрибут group-title тега #EXTINF) + Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTINF + Status int `json:"status"` // Код статуса HTTP + IsOnline bool `json:"isOnline"` // Признак доступности канала (при Status < 400) + Error string `json:"error"` // Текст ошибки (при Status >= 400) + Content string `json:"content"` // Тело ответа (формат m3u, либо маскированные бинарные данные, либо пусто) + ContentType string `json:"contentType"` // MIME-тип тела ответа + Tags []string `json:"tags"` // Список тегов канала + CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp +} + +// Playlist - структура для хранения информации о плейлисте +type Playlist struct { + Code string `json:"code"` // Код плейлиста (из ini-файла) + Name string `json:"name"` // Название плейлиста (из ini-файла) + Description string `json:"description"` // Описание плейлиста (из ini-файла) + Url string `json:"url"` // URL плейлиста + Source string `json:"source"` // Источник плейлиста (из ini-файла) + Content string `json:"content"` // Содержимое плейлиста (m3u, m3u8) + IsOnline bool `json:"isOnline"` // Признак доступности плейлиста + Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTM3U + Groups map[string]Group `json:"groups"` // Группы каналов (по тегам #EXTGRP и атрибутам tvg-group тегов #EXTINF) + Channels map[string]Channel `json:"channels"` // Каналы + OnlineCount int `json:"onlineCount"` // Количество рабочих каналов + OfflineCount int `json:"offlineCount"` // Количество нерабочих каналов + CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp +} + +// tmpChannel хранит временные данные о канале, который обрабатывается в Parse +var tmpChannel = Channel{} + +// MakeFromFile создаёт экземпляр плейлиста из файла +func MakeFromFile(filepath string) (Playlist, error) { + expandedPath, err := utils.ExpandPath(filepath) + if err != nil { + return Playlist{}, errors.New("File read error: " + err.Error()) + } + + _, err = os.Stat(expandedPath) + if err != nil { + return Playlist{}, errors.New("File read error: " + err.Error()) + } + + playlist := Playlist{ + Code: "", + Name: "", + Description: "Playlist from filesystem", + Url: expandedPath, + Source: "-f", + IsOnline: true, + } + + return playlist, playlist.ReadFromFs() +} + +// MakeFromUrl создаёт экземпляр плейлиста из URL-адреса +func MakeFromUrl(url string) (Playlist, error) { + playlist := Playlist{ + Code: "", + Name: "", + Description: "Remote playlist", + Url: url, + Source: "-u", + IsOnline: true, + } + + return playlist, nil +} + +// 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) + for _, match := range regexMatches { + result[match[1]] = match[2] + } + return result +} + +// parseName парсит название канала из строки тега #EXTINF +func parseName(line string) string { + //TODO https://git.axenov.dev/IPTV/iptvc/issues/7 + parts := strings.Split(line, ",") + if len(parts) == 2 { + return strings.Trim(parts[1], " ") + } + + regex := regexp.MustCompile(`['"]?\s*,\s*(.+)`) + regexMatches := regex.FindAllStringSubmatch(line, -1) + return regexMatches[0][1] +} + +// Download загружает плейлист по URL-адресу +func (pls *Playlist) Download() error { + content, err := utils.Fetch(pls.Url) + if err != nil { + return err + } + + pls.Content = string(content) + return nil +} + +// ReadFromFs читает плейлист из файла +func (pls *Playlist) ReadFromFs() error { + content, err := os.ReadFile(pls.Url) + if err != nil { + return err + } + + pls.Content = string(content) + return nil +} + +// Parse разбирает плейлист +func (pls *Playlist) Parse() Playlist { + isChannel := false + pls.Channels = make(map[string]Channel) + pls.Groups = make(map[string]Group) + + content := pls.Content + content = strings.ReplaceAll(content, "\r\n", "\n") // replace windows line endings + content = strings.ReplaceAll(content, "\xef\xbb\xbf", "") // remove UTF8 BOM + + for _, line := range strings.Split(content, "\n") { + line = strings.Trim(line, "\t\r\n ") + if line == "" || line == "#EXTM3U" { + continue + } + + if strings.HasPrefix(line, "#EXTM3U") { + pls.Attributes = parseAttributes(content) + continue + } + + if strings.HasPrefix(line, "#EXTINF") { + isChannel = true + tmpChannel.Attributes = parseAttributes(line) + tmpChannel.Title = parseName(line) + + if tmpChannel.Title == "" { + if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok { + tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")" + } else { + tmpChannel.Title = "(канал без названия, tvg-id неизвестен)" + } + } + + if groupName, ok := tmpChannel.Attributes["group-title"]; ok { + id := utils.Md5str(groupName) + tmpChannel.GroupId = id + pls.Groups[id] = Group{ + Id: id, + Name: groupName, + Attributes: nil, + } + } + continue + } + + if isChannel && strings.HasPrefix(line, "#EXTGRP") { + parts := strings.Split(line, ":") + groupName := strings.Trim(parts[1], " ") + id := utils.Md5str(groupName) + tmpChannel.GroupId = id + pls.Groups[id] = Group{ + Id: id, + Name: groupName, + Attributes: nil, + } + continue + } + + if isChannel && strings.HasPrefix(line, "http") { + tmpChannel.URL = strings.Trim(line, " ") + tmpChannel.Id = utils.Md5str(tmpChannel.URL) + if tmpChannel.Id != "" { + pls.Channels[tmpChannel.Id] = tmpChannel + isChannel = false + tmpChannel = Channel{} + tmpChannel.Attributes = make(map[string]string) + } + } + } + + return *pls +} diff --git a/app/tagfile/tagfile.go b/app/tagfile/tagfile.go new file mode 100644 index 0000000..c6b8131 --- /dev/null +++ b/app/tagfile/tagfile.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package tagfile + +import ( + "axenov/iptv-checker/app/playlist" + "axenov/iptv-checker/app/utils" + "encoding/json" + "log" + "os" + "regexp" + "strings" +) + +// TagBlock описывает объект с набором тегов, который подходит для каналов по регулярному выражению +type TagBlock struct { + TvgId string `json:"tvg-id"` + Title string `json:"title"` + Tags []string `json:"tags"` +} + +// GetTags возвращает теги, соответствующие каналу +func (block *TagBlock) GetTags(ch playlist.Channel) []string { + var regex *regexp.Regexp + var checkString string + var err error + result := make([]string, 0) + + if block.TvgId != "" { + regex, err = regexp.Compile(block.TvgId) + if err != nil { + return result + } + if _, ok := ch.Attributes["tvg-id"]; !ok { + return result + } + checkString = ch.Attributes["tvg-id"] + if checkString == "" { + return result + } + } else if block.Title != "" { + regex, err = regexp.Compile(block.Title) + if err != nil { + return result + } + checkString = ch.Title + } else { + return result + } + + checkString = strings.ToLower(checkString) + check := regex.MatchString(checkString) + if !check { + return result + } + return block.Tags +} + +// Init инициализирует объекты тегов из тегфайла +func Init(path string) []TagBlock { + pathNormalized, _ := utils.ExpandPath(path) + _, err := os.Stat(pathNormalized) + if err != nil { + log.Println("Warning: tagfile load error (", err, "), all channels will be untagged") + return nil + } + + content, err := os.ReadFile(pathNormalized) + if err != nil { + log.Println("Warning: tagfile load error (", err, "), all channels will be untagged") + return nil + } + + var blocks []TagBlock + err = json.Unmarshal(content, &blocks) + if err != nil { + log.Println("Warning: tagfile load error (", err, "), all channels will be untagged") + return nil + } + + //TODO валидация полей: обязательны tvg-id или title, tags может быть пустым + return blocks +} diff --git a/app/utils/utils.go b/app/utils/utils.go new file mode 100644 index 0000000..ff4ded7 --- /dev/null +++ b/app/utils/utils.go @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package utils + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// ExpandPath возвращает полный путь к файлу, где ~ заменяется на домашнюю папку пользователя +func ExpandPath(path string) (string, error) { + homepath, err := os.UserHomeDir() + if err != nil { + return "", err + } + + newpath, err := filepath.Abs(strings.Replace(path, "~", homepath, 1)) + return newpath, err +} + +// ArrayUnique возвращает массив уникальных элементов +func ArrayUnique(arr []string) []string { + size := len(arr) + result := make([]string, 0, size) + temp := map[string]struct{}{} + for i := 0; i < size; i++ { + if _, ok := temp[arr[i]]; ok != true { + temp[arr[i]] = struct{}{} + result = append(result, arr[i]) + } + } + return result +} + +// Fetch выполняет GET запрос и возвращает результат в виде массива байт +func Fetch(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set( + "User-Agent", + "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer", + ) + + httpClient := http.Client{Timeout: 5 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// Md5str возвращает хэш строки в виде строки +func Md5str(str string) string { + hash := md5.Sum([]byte(str)) + return hex.EncodeToString(hash[:]) +} diff --git a/cmd/check.go b/cmd/check.go new file mode 100644 index 0000000..3b22abf --- /dev/null +++ b/cmd/check.go @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package cmd + +import ( + "axenov/iptv-checker/app" + "axenov/iptv-checker/app/checker" + "axenov/iptv-checker/app/logger" + "github.com/spf13/cobra" + "log" + "os" +) + +// checkCmd represents the file command +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Check playlists", + Run: func(cmd *cobra.Command, args []string) { + logger.Init(app.Args.NeedQuiet) + + files, _ := cmd.Flags().GetStringSlice("file") + urls, _ := cmd.Flags().GetStringSlice("url") + codes, _ := cmd.Flags().GetStringSlice("code") + + if len(files) < 1 && len(urls) < 1 && len(codes) < 1 { + log.Println("ERROR: You should provide at least one of --file, --url or --code flags") + os.Exit(2) + } + + lists := checker.PrepareListsToCheck(files, urls, codes) + checker.CheckPlaylists(lists) + }, +} + +func init() { + checkCmd.Flags().StringVarP(&app.Args.TagsPath, "tags", "t", "./channels.json", "path to a local tagfile") + checkCmd.Flags().StringVarP(&app.Args.IniPath, "ini", "i", "./playlists.ini", "path to a local ini-file") + checkCmd.Flags().UintVarP(&app.Args.RandomCount, "random", "r", 0, "take this count of random playlists to check from ini-file") + checkCmd.Flags().BoolVarP(&app.Args.NeedJson, "json", "j", false, "print results in JSON format in the end") + checkCmd.Flags().BoolVarP(&app.Args.NeedQuiet, "quiet", "q", false, "suppress logs (does not affect on -j)") + checkCmd.Flags().StringSliceP("file", "f", []string{}, "path to a local playlist file (m3u/m3u8)") + checkCmd.Flags().StringSliceP("url", "u", []string{}, "URL to a remote playlist (http/https)") + checkCmd.Flags().StringSliceP("code", "c", []string{}, "code of playlist from ini-file") + rootCmd.AddCommand(checkCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ce0c032 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package cmd + +import ( + "axenov/iptv-checker/app" + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "iptvc", + Short: "Simple utility to check iptv playlists. Part of iptv.axenov.dev project.", + Long: `Simple utility to check iptv playlists. Part of iptv.axenov.dev project. +Copyright (c) 2025, Антон Аксенов, MIT license.`, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + app.Init() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().BoolVarP(&app.Args.Verbose, "verbose", "v", false, "enable additional output") +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..af46917 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package cmd + +import ( + "axenov/iptv-checker/app" + "fmt" + + "github.com/spf13/cobra" +) + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("iptvc v" + app.VERSION) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 0000000..017c369 --- /dev/null +++ b/docs/json.md @@ -0,0 +1,114 @@ +# Результаты в формате JSON + +Результаты проверки могут быть выведены в формате JSON. +Для этого необходимо указать аргумент `-j` перед вызовом программы. + +Структуры описаны файлах: +- [Канал](../app/playlist/channel.go) +- [Группа](../app/playlist/group.go) +- [Плейлист](../app/playlist/playlist.go) + +Ниже описан пример результата проверки плейлиста с четырьмя каналами: +1. `01df21bb720e46fa8b3a1c064effa41f` доступен для просмотра, сервер отдаёт m3u с разными потоками; +2. `36a32a9741e869d182c21beb38dce0c9` доступен для просмотра, сервер отдаёт сразу один поток с бинарными данными, в ответе они скрыты строкой `"binary"`; +3. `2dab47162d78814e2a55b65c292d91e7` не доступен для просмотра, сервер отдаёт ошибку 403 и html страницы-заглушки; +4. `d5e815a6cb24221b317afc3eb436b537` не доступен для просмотра, произошла ошибка при проверке. + +```json +[ + { + "code": "ru", + "name": "[iptv-org] Российские", + "description": "", + "url": "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/ru.m3u", + "source": "https://github.com/iptv-org/iptv", + "content": "#EXTM3U\n#EXTINF:-1 tvg-id=\"vijuPlusComedy.ru\",viju+ Comedy (1080p)\nhttp://77.235.1.17/vip_comedy/index.m3u8\n#EXTINF:-1 tvg-id=\"NanoHD.ru\",Нано ТВ HD\nhttp://s1.tv-nano.com/Nano_rec/index.m3u8\n#EXTINF:-1 tvg-id=\"\",TRK 555 (720p)\nhttp://trk555.tv:8888/live\n#EXTINF:-1 tvg-id=\"NizhniyNovgorod24.ru\",Нижний Новгород 24 (720p) [Not 24/7]\nhttps://live-vestinn.cdnvideo.ru/vestinn/nn24-khl/playlist.m3u8\n", + "isOnline": true, + "attributes": null, + "groups": {}, + "channels": { + "01df21bb720e46fa8b3a1c064effa41f": { + "id": "01df21bb720e46fa8b3a1c064effa41f", + "title": "Нано ТВ HD", + "url": "http://s1.tv-nano.com/Nano_rec/index.m3u8", + "groupId": "", + "attributes": { + "tvg-id": "NanoHD.ru" + }, + "status": 200, + "isOnline": true, + "error": "", + "content": "#EXTM3U\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=670000,BANDWIDTH=840000,RESOLUTION=480x270,FRAME-RATE=25.000,CODECS=\"avc1.4d400d,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v3a1/mono.ts.m3u8\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1320000,BANDWIDTH=1650000,RESOLUTION=640x360,FRAME-RATE=25.000,CODECS=\"avc1.4d4015,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v2a1/mono.ts.m3u8\n#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2380000,BANDWIDTH=2980000,RESOLUTION=1024x576,FRAME-RATE=25.000,CODECS=\"avc1.4d401e,mp4a.40.2\",CLOSED-CAPTIONS=NONE\ntracks-v1a1/mono.ts.m3u8\n", + "contentType": "application/vnd.apple.mpegurl", + "tags": [ + "untagged" + ], + "checkedAt": 1234567890 + }, + "36a32a9741e869d182c21beb38dce0c9": { + "id": "36a32a9741e869d182c21beb38dce0c9", + "title": "TRK 555 (720p)", + "url": "http://trk555.tv:8888/live", + "groupId": "", + "attributes": { + "tvg-id": "" + }, + "status": 200, + "isOnline": true, + "error": "", + "content": "\u003cbinary\u003e", + "contentType": "application/octet-stream", + "tags": [ + "untagged" + ], + "checkedAt": 1234567890 + }, + "2dab47162d78814e2a55b65c292d91e7": { + "id": "2dab47162d78814e2a55b65c292d91e7", + "title": "Нижний Новгород 24 (720p) [Not 24/7]", + "url": "https://live-vestinn.cdnvideo.ru/vestinn/nn24-khl/playlist.m3u8", + "groupId": "", + "attributes": { + "tvg-id": "NizhniyNovgorod24.ru" + }, + "status": 403, + "isOnline": false, + "error": "\u003chtml\u003e\r\n\u003chead\u003e\u003ctitle\u003e403 Forbidden\u003c/title\u003e\u003c/head\u003e\r\n\u003cbody\u003e\r\n\u003ccenter\u003e\u003ch1\u003e403 Forbidden\u003c/h1\u003e\u003c/center\u003e\r\n\u003chr\u003e\u003ccenter\u003enginx\u003c/center\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n", + "content": "", + "contentType": "text/html", + "tags": [ + "RU", + "local", + "news" + ], + "checkedAt": 1234567890 + }, + "d5e815a6cb24221b317afc3eb436b537": { + "id": "d5e815a6cb24221b317afc3eb436b537", + "title": "viju+ Comedy (1080p)", + "url": "http://77.235.1.17/vip_comedy/index.m3u8", + "groupId": "", + "attributes": { + "tvg-id": "vijuPlusComedy.ru" + }, + "status": 0, + "isOnline": false, + "error": "", + "content": "", + "contentType": "", + "tags": [ + "untagged" + ], + "checkedAt": 1234567890 + } + }, + "onlineCount": 2, + "offlineCount": 2, + "checkedAt": 1234567890 + } +] +``` + +На этом примере видно, что: +- `channels` не массив, а объект, в котором ключи равны идентификаторам каналов `id`; +- `attributes` не массив, а объект, в котором ключи есть названия атрибутов. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..62ada80 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module axenov/iptv-checker + +go 1.23.6 + +require ( + github.com/redis/go-redis/v9 v9.7.3 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/testify v1.7.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dfb2257 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..40593f4 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025, Антон Аксенов + * This file is part of iptvc project + * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE + */ + +package main + +import ( + "axenov/iptv-checker/cmd" +) + +func main() { + cmd.Execute() +}