28 Commits

Author SHA1 Message Date
aabad9d744 Линтовка 2026-01-03 01:12:18 +08:00
ddc4374dd6 Организация docker-окружения 2026-01-03 01:11:59 +08:00
5c1b19c08a Линтеры 2025-12-09 22:17:50 +08:00
ffaed2b657 Хелперы 2025-12-09 11:28:56 +08:00
006533169f Оптимизация списка каналов и поиска + мелочи по текстовкам 2025-12-09 00:56:13 +08:00
d1efb0dcd3 Мелочи по README 2025-12-02 11:13:05 +08:00
23f388172c В списке теперь не выводятся возможности листов если они не онлайн 2025-11-04 17:54:19 +08:00
26f73d31c3 Ссылка на status.m3u.su 2025-11-04 16:48:09 +08:00
b9ac2e013a Фикс ответа бота о статистике 2025-11-01 01:09:07 +08:00
b5d3b60356 Новые роуты API для статистики и мониторинга
- /api/version
- /api/health
- /api/stats
2025-11-01 00:58:25 +08:00
c75da39b87 Рефакторинг статистики. Добавлен latest 2025-11-01 00:57:38 +08:00
67349bb909 Ссылка на репозиторий через env 2025-11-01 00:56:39 +08:00
07692b08ce Фикс 404 2025-10-30 09:35:05 +00:00
a491ffe6d4 Ссылка на statuser 2025-10-29 00:54:57 +08:00
ba8d59644c Мелочи по косметике
- вывод деталей о листе только при онлайн статусе
- копирование ссылки без промпта
- мелочи по подсказкам к разным элементам
2025-10-29 00:40:47 +08:00
3b0e1d8f18 Первичная проверка стабильности плейлистов/каналов 2025-10-28 11:32:25 +08:00
a93e427bb0 Корректировка копирайтов 2025-10-28 11:24:47 +08:00
c47481795b Оптимизация вычитки листов из кэша для генерации qr-кодов 2025-10-27 08:33:06 +08:00
993625aa8f Оптимизация вычитки листов из кэша с пагинацией, удалены количества онлайн/офлайн листов 2025-10-22 00:59:56 +08:00
71304f6d84 Оптимизация вычитки листа из кэша 2025-10-22 00:22:16 +08:00
1b601b39bf Микрооптимизация вычитки листов из кэша 2025-10-21 23:58:13 +08:00
65c9250c41 Фикс пагинации 2025-10-21 16:14:35 +08:00
4ee3ae6487 Добавлен процент рабочих каналов на главную страницу 2025-10-21 12:36:52 +08:00
3eb29a169d Добавлено процентное отношение (не)рабочих каналов на страницу плейлиста 2025-10-21 12:30:59 +08:00
1f0337768e Новая вкладка с инфой для правообладателей на странице плейлиста 2025-10-05 00:35:28 +08:00
5194e03625 Исправлены адреса сайта 2025-10-02 23:44:12 +08:00
17b9f465d7 Мелочи по конфигам, восстановлен APP_TITLE 2025-09-19 12:17:26 +08:00
e3df9a6670 Merge pull request 'restyle' (#9) from restyle into master
Reviewed-on: #9
2025-07-19 13:22:21 +00:00
40 changed files with 8618 additions and 891 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
/docker
/.git
/.idea
/.vscode
.phpunit.result.cache
.php-cs-fixer.cache
*.cache
*.log

View File

@@ -3,24 +3,24 @@
###################################### ######################################
# config/app.php # config/app.php
APP_URL="http://localhost:8080" APP_TITLE='Проверка плейлистов'
APP_URL_MIRROR="https://m3u.su/" APP_URL=http://localhost:8080
APP_DEBUG=false APP_DEBUG=false
APP_ENV="prod" APP_ENV=prod
APP_TITLE="IPTV Плейлисты"
APP_TIMEZONE=Europe/Moscow APP_TIMEZONE=Europe/Moscow
PAGE_SIZE=10 PAGE_SIZE=0
REPO_URL='https://git.axenov.dev/IPTV'
# config/bot.php # config/api.php
TG_BOT_TOKEN= TG_BOT_TOKEN=
TG_BOT_SECRET= TG_BOT_SECRET=
# config/cache.php # config/cache.php
CACHE_HOST="keydb" CACHE_HOST=keydb
CACHE_PORT=6379 CACHE_PORT=6379
CACHE_PASSWORD= CACHE_PASSWORD=
CACHE_DB=0 CACHE_DB=0
CACHE_TTL=14 CACHE_TTL=600
# config/twig.php # config/twig.php
TWIG_USE_CACHE=true TWIG_USE_CACHE=true

4
.gitignore vendored
View File

@@ -7,8 +7,10 @@
*.log *.log
.env .env
.env.* .env.*
!.env.example
playlists.ini playlists.ini
channels.json channels.json
!.env.example .phpunit.result.cache
.php-cs-fixer.cache
!/**/.gitkeep !/**/.gitkeep

732
.php-cs-fixer.php Normal file
View File

@@ -0,0 +1,732 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
// директории для проверки
$includePaths = [
'app/',
'config/',
];
// файлы для проверки
$includeNames = [
'public/index.php',
];
// директории, исключённые из проверки
$excludePaths = [
'cache/',
'views/',
'vendor/',
];
// файлы, исключённые из проверки
$excludeNames = [
];
// правила
// https://cs.symfony.com/doc/rules/index.html
// https://mlocati.github.io/php-cs-fixer-configurator/#version:3.83
$rules = [
// Alias
'array_push' => true,
'backtick_to_shell_exec' => true,
'ereg_to_preg' => true,
'mb_str_functions' => false,
'modernize_strpos' => true,
'no_alias_functions' => ['sets' => ['@all']],
'no_alias_language_construct_call' => true,
'no_mixed_echo_print' => ['use' => 'echo'],
'pow_to_exponentiation' => true,
'random_api_migration' => true,
'set_type_to_cast' => false,
// Array Notation
'array_syntax' => ['syntax' => 'short'],
'no_multiline_whitespace_around_double_arrow' => true,
'no_whitespace_before_comma_in_array' => ['after_heredoc' => false],
'normalize_index_brace' => true,
'return_to_yield_from' => false,
'trim_array_spaces' => true,
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
'yield_from_array_to_yields' => false,
// Attribute Notation
'attribute_empty_parentheses' => false,
'ordered_attributes' => ['order' => [], 'sort_algorithm' => 'alpha'],
// Basic
'braces_position' => [
'allow_single_line_anonymous_functions' => false,
'allow_single_line_empty_anonymous_classes' => false,
'anonymous_classes_opening_brace' => 'next_line_unless_newline_at_signature_end',
'anonymous_functions_opening_brace' => 'same_line',
'classes_opening_brace' => 'next_line_unless_newline_at_signature_end',
'control_structures_opening_brace' => 'same_line',
'functions_opening_brace' => 'next_line_unless_newline_at_signature_end',
],
'encoding' => true,
'no_multiple_statements_per_line' => true,
'no_trailing_comma_in_singleline' => [
'elements' => [
'arguments',
'array',
'array_destructuring',
'group_import',
],
],
'non_printable_character' => ['use_escape_sequences_in_strings' => true],
'numeric_literal_separator' => ['override_existing' => false, 'strategy' => 'no_separator'],
'octal_notation' => true,
'psr_autoloading' => ['dir' => null],
// Casing
'class_reference_name_casing' => true,
'constant_case' => ['case' => 'lower'],
'integer_literal_case' => true,
'lowercase_keywords' => true,
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
'native_function_casing' => true,
'native_type_declaration_casing' => true,
// Cast Notation
'cast_spaces' => ['space' => 'single'],
'lowercase_cast' => true,
'modernize_types_casting' => true,
'no_short_bool_cast' => true,
'no_unset_cast' => true,
'short_scalar_cast' => true,
// Class Notation
'class_attributes_separation' => [
'elements' => [
'const' => 'one',
'method' => 'one',
'property' => 'one',
'trait_import' => 'none',
'case' => 'one',
],
],
'class_definition' => [
'inline_constructor_arguments' => true,
'multi_line_extends_each_single_line' => true,
'single_item_single_line' => true,
'single_line' => false,
'space_before_parenthesis' => false,
],
'final_class' => false,
'final_internal_class' => [
'consider_absent_docblock_as_internal_class' => false,
'exclude' => [],
'include' => ['@IamTotallySureThisClassMustBeFinal'],
],
'final_public_method_for_abstract_class' => false,
'no_blank_lines_after_class_opening' => true,
'no_null_property_initialization' => false,
'no_php4_constructor' => true,
'no_unneeded_final_method' => ['private_methods' => true],
'ordered_class_elements' => [
'case_sensitive' => false,
'order' => [
'use_trait',
'case',
'constant_public',
'constant_protected',
'constant_private',
'property_public',
'property_protected',
'property_private',
'construct',
'destruct',
'method_public',
'method_protected',
'method_private',
'magic',
'phpunit',
],
'sort_algorithm' => 'none',
],
'ordered_interfaces' => [
'case_sensitive' => false,
'direction' => 'ascend',
'order' => 'alpha',
],
'ordered_traits' => [
'case_sensitive' => false,
],
'ordered_types' => [
'case_sensitive' => false,
'null_adjustment' => 'always_last',
'sort_algorithm' => 'alpha',
],
'phpdoc_readonly_class_comment_to_keyword' => true,
'protected_to_private' => false,
'self_accessor' => true,
'self_static_accessor' => true,
'single_class_element_per_statement' => ['elements' => ['const', 'property']],
'single_trait_insert_per_statement' => false,
'modifier_keywords' => ['elements' => ['const', 'method', 'property']],
// Class Usage
'date_time_immutable' => false, // при true ломает строку 'DateTime', что пока весьма важно
// Comment
'comment_to_phpdoc' => ['ignored_tags' => []],
// 'header_comment' => ['header' => ''],
'multiline_comment_opening_closing' => false,
'no_empty_comment' => true,
'no_trailing_whitespace_in_comment' => true,
'single_line_comment_spacing' => true,
'single_line_comment_style' => ['comment_types' => ['asterisk', 'hash']],
// Constant Notation
'native_constant_invocation' => [
'exclude' => [],
'include' => [],
'fix_built_in' => false,
'scope' => 'all',
'strict' => false,
],
// Control Structure
'single_line_empty_body' => false, // иначе ругается на пустые классы
'control_structure_braces' => true,
'control_structure_continuation_position' => ['position' => 'same_line'],
'elseif' => true,
'empty_loop_body' => ['style' => 'braces'],
'empty_loop_condition' => ['style' => 'while'],
'include' => true,
'no_alternative_syntax' => ['fix_non_monolithic_code' => true],
'no_break_comment' => ['comment_text' => 'намеренно не останавливаем выполнение'],
'no_superfluous_elseif' => true,
'no_unneeded_braces' => ['namespaces' => false],
'no_unneeded_control_parentheses' => [
'statements' => ['break', 'clone', 'continue', 'echo_print', 'return', 'switch_case', 'yield', 'yield_from'],
],
'no_useless_else' => true,
'simplified_if_return' => true,
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
'switch_continue_to_break' => true,
'trailing_comma_in_multiline' => true,
'yoda_style' => [
'equal' => false,
'identical' => false,
'less_and_greater' => false,
],
// Doctrine Annotation
// (не используем)
// Function Notation
'combine_nested_dirname' => true,
'date_time_create_from_format_call' => true,
'fopen_flag_order' => false,
// TODO 'fopen_flags' => ['b_mode' => false],
'function_declaration' => [
'closure_fn_spacing' => 'one',
'closure_function_spacing' => 'one',
'trailing_comma_single_line' => false,
],
'implode_call' => true,
'lambda_not_used_import' => true,
'method_argument_space' => [
'after_heredoc' => true,
'attribute_placement' => 'standalone',
// TODO 'keep_multiple_spaces_after_comma' => true,
'on_multiline' => 'ensure_fully_multiline',
],
'native_function_invocation' => [
'exclude' => ['@all'],
'include' => [],
'scope' => 'all',
'strict' => false,
],
'no_spaces_after_function_name' => true,
'no_unreachable_default_argument_value' => true,
'no_useless_sprintf' => true,
// 'phpdoc_to_param_type' => [ // risky, experimenal :(
// 'scalar_types' => true,
// 'union_types' => true,
// ],
// 'phpdoc_to_property_type' => [ // risky, experimenal :(
// 'scalar_types' => true,
// 'union_types' => true,
// ],
// 'phpdoc_to_return_type' => [ // risky, experimenal :(
// 'scalar_types' => true,
// 'union_types' => true,
// ],
'regular_callable_call' => true,
'return_type_declaration' => ['space_before' => 'none'],
'single_line_throw' => false,
'static_lambda' => true,
'use_arrow_functions' => true,
'void_return' => true,
'nullable_type_declaration_for_default_null_value' => true,
// Import
'fully_qualified_strict_types' => [
'import_symbols' => true,
'leading_backslash_in_global_namespace' => false,
'phpdoc_tags' => [
'param',
'property',
'property-read',
'property-write',
'phpstan-param',
'phpstan-property',
'phpstan-property-read',
'phpstan-property-write',
'phpstan-return',
'phpstan-var',
'psalm-param',
'psalm-property',
'psalm-property-read',
'psalm-property-write',
'psalm-return',
'psalm-var',
'return',
'see',
'link',
'throws',
'var',
],
],
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => false,
'import_functions' => true,
],
'group_import' => false,
'no_leading_import_slash' => true,
'no_unneeded_import_alias' => true,
'no_unused_imports' => true,
'ordered_imports' => [
'case_sensitive' => false,
'imports_order' => ['class', 'const', 'function'],
'sort_algorithm' => 'alpha',
],
'single_import_per_statement' => ['group_to_single_imports' => true],
'single_line_after_imports' => true,
// Language Construct
'class_keyword' => false, // при true ломает строку 'DateTime', что пока весьма важно
'combine_consecutive_issets' => true,
'combine_consecutive_unsets' => false, // иначе местами приводит к превышению длины строки
'declare_equal_normalize' => ['space' => 'none'],
'declare_parentheses' => true,
'dir_constant' => true,
'error_suppression' => false,
'explicit_indirect_variable' => true,
'function_to_constant' => [
'functions' => [
'get_called_class',
'get_class',
'get_class_this',
'php_sapi_name',
'phpversion',
'pi',
],
],
'get_class_to_class_keyword' => true,
'is_null' => false,
'no_unset_on_property' => false,
'nullable_type_declaration' => ['syntax' => 'question_mark'],
'single_space_around_construct' => [
'constructs_contain_a_single_space' => ['yield_from'],
'constructs_followed_by_a_single_space' => [
'abstract',
'as',
'attribute',
// 'break',
'case',
'catch',
'class',
'clone',
'comment',
'const',
'const_import',
'continue',
'do',
'echo',
'else',
'elseif',
'enum',
'extends',
'final',
'finally',
'for',
'foreach',
'function',
'function_import',
'global',
'goto',
'if',
'implements',
'include',
'include_once',
'instanceof',
'insteadof',
'interface',
'match',
'named_argument',
'namespace',
'new',
'open_tag_with_echo',
'php_doc',
'php_open',
'print',
'private',
'protected',
'public',
'readonly',
'require',
'require_once',
'return',
'static',
'switch',
'throw',
'trait',
'try',
'type_colon',
'use',
'use_lambda',
'use_trait',
'var',
'while',
'yield',
'yield_from',
],
'constructs_preceded_by_a_single_space' => ['as', 'else', 'elseif', 'use_lambda'],
],
// List Notation
'list_syntax' => ['syntax' => 'short'],
// Namespace Notation
'blank_line_after_namespace' => true,
'blank_lines_before_namespace' => ['max_line_breaks' => 2, 'min_line_breaks' => 2],
'clean_namespace' => true,
'no_leading_namespace_whitespace' => true,
// Naming
'no_homoglyph_names' => true,
// Operator
'assign_null_coalescing_to_coalesce_equal' => true,
'binary_operator_spaces' => [
'default' => 'single_space',
'operators' => [],
],
'concat_space' => ['spacing' => 'one'],
'increment_style' => ['style' => 'pre'],
'logical_operators' => true,
'long_to_shorthand_operator' => true,
'new_with_parentheses' => ['anonymous_class' => true, 'named_class' => true],
'no_space_around_double_colon' => true,
'no_useless_concat_operator' => ['juggle_simple_strings' => false],
'no_useless_nullsafe_operator' => true,
'not_operator_with_space' => false,
'not_operator_with_successor_space' => false,
'object_operator_without_whitespace' => true,
'operator_linebreak' => [
'only_booleans' => true,
'position' => 'beginning',
],
'standardize_increment' => true,
'standardize_not_equals' => true,
'ternary_operator_spaces' => true,
'ternary_to_elvis_operator' => false,
'ternary_to_null_coalescing' => true,
'unary_operator_spaces' => ['only_dec_inc' => false],
// PHP Tag
'blank_line_after_opening_tag' => true,
'echo_tag_syntax' => [
'format' => 'short',
'long_function' => 'echo',
'shorten_simple_statements_only' => true,
],
'full_opening_tag' => true,
'linebreak_after_opening_tag' => true,
'no_closing_tag' => true,
// PHPUnit
'php_unit_assert_new_names' => true,
'php_unit_attributes' => ['keep_annotations' => false],
'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']],
'php_unit_data_provider_name' => ['prefix' => 'provide', 'suffix' => ''],
'php_unit_data_provider_return_type' => false,
'php_unit_data_provider_static' => ['force' => true],
'php_unit_dedicate_assert' => ['target' => 'newest'],
'php_unit_dedicate_assert_internal_type' => ['target' => 'newest'],
'php_unit_expectation' => ['target' => 'newest'],
'php_unit_fqcn_annotation' => true,
'php_unit_internal_class' => false,
'php_unit_method_casing' => ['case' => 'camel_case'],
'php_unit_mock' => ['target' => 'newest'],
'php_unit_mock_short_will_return' => true,
'php_unit_namespaced' => ['target' => 'newest'],
'php_unit_no_expectation_annotation' => ['target' => 'newest', 'use_class_const' => true],
'php_unit_set_up_tear_down_visibility' => true,
'php_unit_size_class' => false,
'php_unit_strict' => [
'assertions' => [
'assertAttributeEquals',
'assertAttributeNotEquals',
// 'assertEquals', // не всегда возможно
// 'assertNotEquals', // не всегда возможно
],
],
'php_unit_test_annotation' => ['style' => 'prefix'],
'php_unit_test_case_static_method_calls' => ['call_type' => 'this', 'methods' => []],
'php_unit_test_class_requires_covers' => false,
// PHPDoc
'align_multiline_comment' => ['comment_type' => 'phpdocs_only'],
'general_phpdoc_annotation_remove' => [
'annotations' => ['author', 'package', 'subpackage'],
'case_sensitive' => false,
],
'general_phpdoc_tag_rename' => [
'case_sensitive' => false,
'fix_annotation' => true,
'fix_inline' => true,
'replacements' => [
'inheritDocs' => 'inheritDoc',
// 'link' => 'see', // phpdoc_no_alias_tag
],
],
'no_blank_lines_after_phpdoc' => true,
'no_empty_phpdoc' => true,
'no_superfluous_phpdoc_tags' => false,
'phpdoc_add_missing_param_annotation' => ['only_untyped' => true],
'phpdoc_align' => [
'align' => 'left',
'spacing' => 1,
'tags' => [
'param',
'property',
'property-read',
'property-write',
'phpstan-param',
'phpstan-property',
'phpstan-property-read',
'phpstan-property-write',
'phpstan-return',
'phpstan-var',
'psalm-param',
'psalm-property',
'psalm-property-read',
'psalm-property-write',
'psalm-return',
'psalm-var',
'return',
'see',
'link',
'throws',
'var',
],
],
'phpdoc_annotation_without_dot' => true,
'phpdoc_array_type' => false,
'phpdoc_indent' => true,
'phpdoc_inline_tag_normalizer' => [
'tags' => ['example', 'id', 'internal', 'inheritdoc', 'inheritdocs', 'link', 'source', 'see', 'tutorial'],
],
'phpdoc_line_span' => [
'const' => 'multi',
'method' => 'multi',
'property' => 'multi',
],
'phpdoc_list_type' => false,
'phpdoc_no_access' => true,
'phpdoc_no_alias_tag' => [
'replacements' => [
'property-read' => 'property',
'property-write' => 'property',
'type' => 'var',
'link' => 'see',
],
],
'phpdoc_no_empty_return' => false,
'phpdoc_no_package' => false, // general_phpdoc_annotation_remove
'phpdoc_no_useless_inheritdoc' => true,
'phpdoc_order_by_value' => ['annotations' => []],
'phpdoc_order' => ['order' => ['param', 'return', 'throws', 'covers', 'dataProvider']],
'phpdoc_param_order' => true,
'phpdoc_return_self_reference' => [
'replacements' => [
'this' => '$this',
'@this' => '$this',
'$self' => 'self',
'@self' => 'self',
'$static' => 'static',
'@static' => 'static',
],
],
'phpdoc_scalar' => ['types' => ['boolean', 'callback', 'double', 'integer', 'real', 'str']],
'phpdoc_separation' => ['groups' => [], 'skip_unlisted_annotations' => true],
'phpdoc_single_line_var_spacing' => true,
'phpdoc_summary' => false,
'phpdoc_tag_casing' => [
'tags' => [
'param',
'property',
'property-read',
'property-write',
'phpstan-param',
'phpstan-property',
'phpstan-property-read',
'phpstan-property-write',
'phpstan-return',
'phpstan-var',
'psalm-param',
'psalm-property',
'psalm-property-read',
'psalm-property-write',
'psalm-return',
'psalm-var',
'return',
'see',
'link',
'throws',
'var',
],
],
'phpdoc_tag_type' => [
'tags' => [
'api' => 'annotation',
'author' => 'annotation',
'copyright' => 'annotation',
'deprecated' => 'annotation',
'example' => 'annotation',
'global' => 'annotation',
'inheritDoc' => 'annotation',
'internal' => 'annotation',
'license' => 'annotation',
'method' => 'annotation',
'package' => 'annotation',
'param' => 'annotation',
'property' => 'annotation',
'return' => 'annotation',
'see' => 'annotation',
'since' => 'annotation',
'throws' => 'annotation',
'todo' => 'annotation',
'uses' => 'annotation',
'var' => 'annotation',
'version' => 'annotation',
],
],
'phpdoc_to_comment' => false, // при true ломаются докблоки к выражениям, которые обязаны быть докблоками, а не многострочными комментами
'phpdoc_trim_consecutive_blank_line_separation' => true,
'phpdoc_trim' => true,
'phpdoc_types' => ['groups' => ['simple', 'alias', 'meta']],
'phpdoc_types_order' => [
'case_sensitive' => false,
'null_adjustment' => 'always_last',
'sort_algorithm' => 'none',
],
'phpdoc_var_annotation_correct_order' => true,
'phpdoc_var_without_name' => true,
// Return Notation
'no_useless_return' => true,
'return_assignment' => true,
'simplified_null_return' => true,
// Semicolon
'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'],
'no_empty_statement' => true,
'no_singleline_whitespace_before_semicolons' => true,
'semicolon_after_instruction' => true,
'space_after_semicolon' => ['remove_in_empty_for_expressions' => true],
// Strict
'declare_strict_types' => true,
'strict_comparison' => true,
'strict_param' => false,
// String Notation
'explicit_string_variable' => true,
'heredoc_closing_marker' => [
'closing_marker' => 'EOD',
'explicit_heredoc_style' => false,
'reserved_closing_markers' => ['SQL', 'EOF'],
],
'heredoc_to_nowdoc' => false,
'multiline_string_to_heredoc' => false,
'no_binary_string' => true,
'no_trailing_whitespace_in_string' => false,
'simple_to_complex_string_variable' => true,
'single_quote' => ['strings_containing_single_quote_chars' => false],
'string_implicit_backslashes' => [
'double_quoted' => 'escape',
'heredoc' => 'escape',
'single_quoted' => 'unescape',
],
'string_length_to_empty' => false,
'string_line_ending' => true,
// Whitespace
'array_indentation' => true,
'blank_line_before_statement' => ['statements' => ['declare', 'return', 'throw', 'try']],
'blank_line_between_import_groups' => true,
'compact_nullable_type_declaration' => true,
'heredoc_indentation' => ['indentation' => 'start_plus_one'],
'indentation_type' => true,
'line_ending' => true,
'method_chaining_indentation' => true,
'no_extra_blank_lines' => [
'tokens' => [
'attribute',
'break',
'case',
'continue',
'curly_brace_block',
'default',
'extra',
'parenthesis_brace_block',
'return',
'square_brace_block',
'switch',
'throw',
'use',
],
],
'no_spaces_around_offset' => ['positions' => ['inside', 'outside']],
'no_trailing_whitespace' => true,
'no_whitespace_in_blank_line' => true,
'single_blank_line_at_eof' => true,
'spaces_inside_parentheses' => ['space' => 'none'],
'statement_indentation' => ['stick_comment_to_next_continuous_control_statement' => false],
'type_declaration_spaces' => ['elements' => ['function', 'property']],
'types_spaces' => ['space' => 'none', 'space_multiple_catch' => null],
];
$finder = (new Finder())
->ignoreVCSIgnored(true) // игнорируем игнорируемое
->in($includePaths) // добавляем директории
->append($includeNames) // добавляем файлы
->exclude($excludePaths) // исключаем директории
->notPath($excludeNames); // исключаем файлы
return (new Config())
// спорная фигня, пока не активирую
// ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect());
->setFinder($finder)
->setRules($rules) // ставим правила
->setRiskyAllowed(true); // рискованные правила разрешаем, но в общий список добавляем аккуратно

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
FROM php:8.4-fpm AS iptv-php-base
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
RUN apt update && \
apt upgrade -y && \
apt install -y \
git \
unzip \
7zip \
cron \
zlib1g-dev \
imagemagick \
libpng-dev \
libjpeg-dev
# https://pecl.php.net/package/redis
RUN pecl channel-update pecl.php.net && \
pecl install redis-6.1.0
RUN docker-php-ext-enable redis && \
docker-php-ext-configure gd --with-jpeg && \
docker-php-ext-install gd
RUN mkdir -p /var/run/php && \
mkdir -p /var/log/php && \
chmod -R 777 /var/log/php
COPY ./ /var/www
COPY --from=composer /usr/bin/composer /usr/local/bin/composer
RUN git config --global --add safe.directory /var/www
EXPOSE 9000
WORKDIR /var/www
ENTRYPOINT [ "php-fpm", "--nodaemonize" ]
FROM iptv-php-base AS iptv-web-dev
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
# https://pecl.php.net/package/xdebug
RUN pecl install xdebug-3.4.1
RUN composer install
FROM iptv-php-base AS iptv-web-prod
LABEL org.opencontainers.image.authors="Anthony Axenov <anthonyaxenov@gmail.com>"
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader

View File

@@ -1,15 +1,15 @@
# Веб-сервис iptv.axenov.dev # Веб-сервис m3u.su
Содержит исходный код веб-сервиса и консольные инструменты проверки плейлистов и каналов. Содержит исходный код веб-сервиса и консольные инструменты проверки плейлистов и каналов.
Использует [playlists.ini](https://git.axenov.dev/IPTV/playlists) с описанием плейлистов для своей работы. Использует [playlists.ini](https://git.axenov.dev/IPTV/playlists) с описанием плейлистов для своей работы.
> **Веб-сайт:** [iptv.axenov.dev](https://iptv.axenov.dev) > **Веб-сайт:** [m3u.su](https://m3u.su)
> **Зеркало:** [m3u.su](https://m3u.su) > **Документация:** [m3u.su/docs](https://m3u.su/docs)
> Исходный код: [git.axenov.dev/IPTV/web](https://git.axenov.dev/IPTV/web) > Исходный код: [git.axenov.dev/IPTV](https://git.axenov.dev/IPTV)
> Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator) > Telegram-канал: [@iptv_aggregator](https://t.me/iptv_aggregator)
> Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat) > Обсуждение: [@iptv_aggregator_chat](https://t.me/iptv_aggregator_chat)
> Дополнительные сведения: [git.axenov.dev/IPTV/.profile](https://git.axenov.dev/IPTV/.profile) > Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
## О веб-сервисе ## О веб-сервисе
@@ -26,16 +26,16 @@
### Описание переменных окружения ### Описание переменных окружения
* `APP_URL` -- адрес, на котором расположен сервис: используется для генерации ссылок на плейлисты; * `APP_URL` адрес, на котором расположен сервис: используется для генерации ссылок на плейлисты;
* `APP_DEBUG` -- признак отладки сервиса (включает служебный вывод в некоторых местах); * `APP_DEBUG` признак отладки сервиса (включает служебный вывод в некоторых местах);
* `APP_ENV` -- окружение (см. ниже); * `APP_ENV` окружение (см. ниже);
* `APP_TITLE` -- название сервиса: используется для вывода на страницах сайта и в их заголовках; * `APP_TITLE` название сервиса: используется для вывода на страницах сайта и в их заголовках;
* `APP_TIMEZONE` -- часовой пояс, в котором расположен сервер; * `APP_TIMEZONE` часовой пояс, в котором расположен сервер;
* `PAGE_SIZE` -- размер страницы для постраничной навигации на главной странице; * `PAGE_SIZE` размер страницы для постраничной навигации на главной странице;
* `USER_AGENT` -- user-agent для http-клиента, котоырй будет использоваться при подключении к внешним ресурсам; * `USER_AGENT` user-agent для http-клиента, котоырй будет использоваться при подключении к внешним ресурсам;
* `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD`, `CACHE_DB` -- реквизиты подключения к cache/keydb; * `CACHE_HOST`, `CACHE_PORT`, `CACHE_PASSWORD`, `CACHE_DB` реквизиты подключения к cache/keydb;
* `CACHE_TTL` -- количество часов для кэширования информации; * `CACHE_TTL` количество секунд для кэширования информации;
* `TWIG_USE_CACHE` -- признак использования кэша компиляции шаблонов Twig. * `TWIG_USE_CACHE` признак использования кэша компиляции шаблонов Twig.
У каждой переменной есть умолчание на случай отсутствия файла `.env` или её отсутствия в нём. У каждой переменной есть умолчание на случай отсутствия файла `.env` или её отсутствия в нём.
Но некорректные значения некоторых переменных могут привести к фатальным ошибкам. Но некорректные значения некоторых переменных могут привести к фатальным ошибкам.

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -9,6 +10,9 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\Bot;
use App\Core\Kernel;
use App\Core\StatisticsService;
use App\Errors\PlaylistNotFoundException; use App\Errors\PlaylistNotFoundException;
use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions; use chillerlan\QRCode\QROptions;
@@ -39,6 +43,7 @@ class ApiController extends BasicController
if ($playlist['isOnline'] === true) { if ($playlist['isOnline'] === true) {
unset($playlist['content']); unset($playlist['content']);
} }
return $this->responseJson($response, 200, $playlist); return $this->responseJson($response, 200, $playlist);
} catch (PlaylistNotFoundException $e) { } catch (PlaylistNotFoundException $e) {
return $this->responseJsonError($response, 404, $e); return $this->responseJsonError($response, 404, $e);
@@ -55,13 +60,14 @@ class ApiController extends BasicController
*/ */
public function makeQrCode(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface public function makeQrCode(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{ {
$ini = ini()->load();
$code = $request->getAttribute('code'); $code = $request->getAttribute('code');
$codes = array_keys(ini()->getPlaylists()); $codes = array_keys($ini);
if (!in_array($code, $codes, true)) { if (!in_array($code, $codes, true)) {
return $response->withStatus(404); return $response->withStatus(404);
} }
$filePath = cache_path("qr-codes/$code.jpg"); $filePath = cache_path("qr-codes/{$code}.jpg");
if (file_exists($filePath)) { if (file_exists($filePath)) {
$raw = file_get_contents($filePath); $raw = file_get_contents($filePath);
} else { } else {
@@ -70,14 +76,118 @@ class ApiController extends BasicController
'outputType' => QRCode::OUTPUT_IMAGE_JPG, 'outputType' => QRCode::OUTPUT_IMAGE_JPG,
'eccLevel' => QRCode::ECC_L, 'eccLevel' => QRCode::ECC_L,
]); ]);
$data = config('app.mirror_url') ? mirror_url("$code.m3u") : base_url("$code.m3u"); $data = config('app.mirror_url') ? mirror_url("{$code}.m3u") : base_url("{$code}.m3u");
$raw = new QRCode($options)->render($data, $filePath); $raw = new QRCode($options)->render($data, $filePath);
$raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw)); $raw = base64_decode(str_replace('data:image/jpg;base64,', '', $raw));
} }
$mime = mime_content_type($filePath); $mime = mime_content_type($filePath);
$response->getBody()->write($raw); $response->getBody()->write($raw);
return $response->withStatus(200) return $response->withStatus(200)
->withHeader('Content-Type', $mime); ->withHeader('Content-Type', $mime);
} }
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function version(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->responseJson($response, 200, [
'web' => Kernel::VERSION,
'php' => PHP_VERSION,
'keydb' => redis()->info('server')['redis_version'],
'checker' => 'todo',
]);
}
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function health(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
function getSize(string $directory): int
{
$size = 0;
foreach (glob($directory . '/*') as $path) {
is_file($path) && $size += filesize($path);
is_dir($path) && $size += getSize($path);
}
return $size;
}
$tgBotInfo = config('bot.token') ? new Bot()->api->getMe() : null;
$redisInfoServer = redis()->info('server'); // General information about the Redis server
$redisInfoClients = redis()->info('clients'); // Client connections section
$redisInfoMemory = redis()->info('memory'); // Memory consumption related information
$redisInfoPers = redis()->info('persistence'); // RDB and AOF related information
$redisInfoStats = redis()->info('stats'); // General statistics
$redisInfoCpu = redis()->info('cpu'); // CPU consumption statistics
$redisInfoCmd = redis()->info('commandstats'); // Redis command statistics
$redisInfoKeysp = redis()->info('keyspace'); // Database related statistics
$redisInfoErr = redis()->info('errorstats'); // Redis error statistics
$health = [
'fileCache' => [
'tv-logos' => [
'sizeB' => $size = getSize(cache_path('tv-logos')),
'sizeMiB' => round($size / 1024 / 1024, 3),
'count' => count(glob(cache_path('tv-logos') . '/*')),
],
'qr-codes' => [
'sizeB' => $size = getSize(cache_path('qr-codes')),
'sizeMiB' => round($size / 1024 / 1024, 3),
'count' => count(glob(cache_path('qr-codes') . '/*')),
],
],
'telegram' => [
'id' => $tgBotInfo->getId(),
'first_name' => $tgBotInfo->getFirstName(),
'username' => $tgBotInfo->getUsername(),
],
'redis' => [
'isConnected' => redis()->isConnected(),
'info' => [
'server' => [
'uptime_in_seconds' => $redisInfoServer['uptime_in_seconds'],
'uptime_in_days' => $redisInfoServer['uptime_in_days'],
],
'clients' => ['connected_clients' => $redisInfoClients['connected_clients']],
'memory' => $redisInfoMemory,
'persistence' => $redisInfoPers,
'stats' => $redisInfoStats,
'cpu' => $redisInfoCpu,
'commandstats' => $redisInfoCmd,
'keyspace' => $redisInfoKeysp,
'errorstats' => $redisInfoErr,
],
],
];
return $this->responseJson($response, 200, $health);
}
/**
* Возвращает информацию о плейлисте
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
* @throws Exception
*/
public function stats(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->responseJson($response, 200, new StatisticsService()->get());
}
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -49,12 +50,14 @@ class BasicController
* @param array $data * @param array $data
* @return ResponseInterface * @return ResponseInterface
*/ */
protected function responseJson(ResponseInterface $response, int $status, array $data): ResponseInterface protected function responseJson(ResponseInterface $response, int $status, mixed $data): ResponseInterface
{ {
is_scalar($data) && $data = [$data];
$data = array_merge(['timestamp' => time()], $data); $data = array_merge(['timestamp' => time()], $data);
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$response->getBody()->write($json); $response->getBody()->write($json);
return $response->withStatus($status) return $response->withStatus($status)
->withHeader('Content-Type', 'application/json'); ->withHeader('Content-Type', 'application/json');
} }
@@ -98,6 +101,7 @@ class BasicController
array $data = [], array $data = [],
): ResponseInterface { ): ResponseInterface {
$view = Twig::fromRequest($request); $view = Twig::fromRequest($request);
return $view->render($response, $template, $data); return $view->render($response, $template, $data);
} }
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -36,28 +37,25 @@ class WebController extends BasicController
*/ */
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{ {
$playlists = ini()->getPlaylists(); $ini = ini()->load();
$keys = [];
$count = count($playlists); $count = count($ini);
$onlineCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === true));
$uncheckedCount = count(array_filter($playlists, static fn (array $playlist) => $playlist['isOnline'] === null));
$offlineCount = $count - $onlineCount - $uncheckedCount;
$pageSize = config('app.page_size'); $pageSize = config('app.page_size');
if ($pageSize > 0) { if ($pageSize > 0) {
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1); $pageCurrent = (int) ($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
$pageCount = ceil($count / $pageSize); $pageCount = ceil($count / $pageSize);
$offset = max(0, ($pageCurrent - 1) * $pageSize); $offset = max(0, ($pageCurrent - 1) * $pageSize);
$playlists = array_slice($playlists, $offset, $pageSize, true); $ini = array_slice($ini, $offset, $pageSize, true);
$keys = array_keys($ini);
} }
$playlists = ini()->getPlaylists($keys);
return $this->view($request, $response, 'list.twig', [ return $this->view($request, $response, 'list.twig', [
'updatedAt' => ini()->updatedAt(), 'updatedAt' => ini()->updatedAt(),
'playlists' => $playlists, 'playlists' => $playlists,
'count' => $count, 'count' => $count,
'onlineCount' => $onlineCount,
'uncheckedCount' => $uncheckedCount,
'offlineCount' => $offlineCount,
'pageCount' => $pageCount ?? 1, 'pageCount' => $pageCount ?? 1,
'pageCurrent' => $pageCurrent ?? 1, 'pageCurrent' => $pageCurrent ?? 1,
]); ]);
@@ -79,6 +77,7 @@ class WebController extends BasicController
try { try {
$playlist = ini()->getPlaylist($code); $playlist = ini()->getPlaylist($code);
return $response->withHeader('Location', $playlist['url']); return $response->withHeader('Location', $playlist['url']);
} catch (Throwable) { } catch (Throwable) {
return $this->notFound($request, $response); return $this->notFound($request, $response);
@@ -102,6 +101,7 @@ class WebController extends BasicController
try { try {
$playlist = ini()->getPlaylist($code); $playlist = ini()->getPlaylist($code);
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]); return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
} catch (PlaylistNotFoundException) { } catch (PlaylistNotFoundException) {
return $this->notFound($request, $response); return $this->notFound($request, $response);

View File

@@ -1,7 +1,7 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -33,37 +33,35 @@ class Bot
/** /**
* @var BotApi Объект Telegram Bot API * @var BotApi Объект Telegram Bot API
*/ */
protected BotApi $bot; public readonly BotApi $api;
/** /**
* @var Update Объект обновления бота * @var Update Объект обновления бота
*/ */
protected Update $update; protected Update $update;
/**
* @var ServerRequestInterface Пришедший от Telegram запрос
*/
protected ServerRequestInterface $request;
/** /**
* Конструктор * Конструктор
* *
* @param ServerRequestInterface $request * @param ServerRequestInterface|null $request
* @throws InvalidTelegramSecretException * @throws InvalidTelegramSecretException
* @throws JsonException
* @throws InvalidArgumentException
* @throws Exception
*/ */
public function __construct(ServerRequestInterface $request) public function __construct(?ServerRequestInterface $request = null)
{ {
$this->checkSecret($request); $this->api = new BotApi(config('bot.token'));
if ($request) {
$body = json_decode((string)$request->getBody(), true); $this->checkSecret($request);
if (json_last_error() !== JSON_ERROR_NONE) { $this->request = $request;
throw new JsonException(json_last_error_msg());
} }
$this->bot = new BotApi(config('bot.token'));
$this->update = Update::fromResponse($body);
} }
/** /**
* Запсукает обработку команды * Запускает обработку команды
* *
* @return bool * @return bool
* @throws InvalidArgumentException * @throws InvalidArgumentException
@@ -72,7 +70,9 @@ class Bot
*/ */
public function process(): bool public function process(): bool
{ {
$this->parseRequestBody();
$commandText = $this->getBotCommandText(); $commandText = $this->getBotCommandText();
return match (true) { return match (true) {
str_starts_with($commandText, '/start') => $this->processHelpCommand(), str_starts_with($commandText, '/start') => $this->processHelpCommand(),
str_starts_with($commandText, '/list') => $this->processListCommand(), str_starts_with($commandText, '/list') => $this->processListCommand(),
@@ -84,6 +84,22 @@ class Bot
}; };
} }
/**
* Подготавливает объект события бота
*
* @return void
* @throws InvalidArgumentException
* @throws JsonException
*/
protected function parseRequestBody(): void
{
$body = json_decode((string)$this->request->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new JsonException(json_last_error_msg());
}
$this->update = Update::fromResponse($body);
}
/** /**
* Обрабатывает команду /list * Обрабатывает команду /list
* *
@@ -94,7 +110,7 @@ class Bot
*/ */
protected function processListCommand(): bool protected function processListCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$playlists = ini()->getPlaylists(); $playlists = ini()->getPlaylists();
if (empty($playlists)) { if (empty($playlists)) {
@@ -139,7 +155,7 @@ class Bot
*/ */
protected function processInfoCommand(): bool protected function processInfoCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$message = $this->update->getMessage(); $message = $this->update->getMessage();
$text = $message->getText(); $text = $message->getText();
@@ -200,7 +216,7 @@ class Bot
} }
} }
$replyText[] = "🔗 *Ссылка для ТВ:* \(скопируй подходящую\)"; $replyText[] = "🔗 *Короткая ссылка:* \(скопируй подходящую\)";
if (config('app.mirror_url')) { if (config('app.mirror_url')) {
$replyText[] = '\- `' . mirror_url("$code") . '`'; $replyText[] = '\- `' . mirror_url("$code") . '`';
$replyText[] = '\- `' . mirror_url("$code.m3u") . '`'; $replyText[] = '\- `' . mirror_url("$code.m3u") . '`';
@@ -235,7 +251,7 @@ class Bot
*/ */
protected function processHelpCommand(): bool protected function processHelpCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' . $replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
$this->escape(base_url()) . '\.'; $this->escape(base_url()) . '\.';
@@ -265,17 +281,16 @@ class Bot
*/ */
protected function processLinksCommand(): bool protected function processLinksCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = '*Ресурсы и страницы*'; $replyText[] = '*Ресурсы и страницы*';
$replyText[] = ''; $replyText[] = '';
$replyText[] = '🌏 Сайт: ' . $this->escape(base_url()); $replyText[] = '🌏 Сайт: ' . $this->escape(base_url());
config('app.mirror_url') && $replyText[] = '🪞 Зеркало: ' . $this->escape(mirror_url()); $replyText[] = '👩‍💻 Исходный код: ' . $this->escape(config('app.repo_url'));
$replyText[] = '👩‍💻 Исходный код: ' . $this->escape('https://git.axenov.dev/IPTV');
$replyText[] = '✈️ Telegram\-канал: @iptv\_aggregator'; $replyText[] = '✈️ Telegram\-канал: @iptv\_aggregator';
$replyText[] = '✈️ Обсуждение: @iptv\_aggregator\_chat'; $replyText[] = '✈️ Обсуждение: @iptv\_aggregator\_chat';
$replyText[] = '📚 Доп\. сведения:'; $replyText[] = '📚 Доп\. сведения:';
$replyText[] = '\- ' . $this->escape('https://git.axenov.dev/IPTV/.profile'); $replyText[] = '\- ' . $this->escape(config('app.repo_url') . '/.profile');
$replyText[] = '\- ' . $this->escape(base_url('faq')); $replyText[] = '\- ' . $this->escape(base_url('faq'));
return $this->reply(implode("\n", $replyText)); return $this->reply(implode("\n", $replyText));
@@ -289,87 +304,30 @@ class Bot
*/ */
protected function processStatsCommand(): bool protected function processStatsCommand(): bool
{ {
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing'); $this->api->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$stats = new StatisticsService()->get();
$allChannels = [];
foreach (ini()->getPlaylists() as $pls) {
$allChannels = array_merge($allChannels, $pls['channels'] ?? []);
}
$onlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === true,
);
$offlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === false,
);
$unknownPls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === null,
);
$adultPls = array_filter(
$onlinePls,
static fn (array $pls) => in_array('adult', $pls['tags']),
);
$catchupPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasCatchup'] === true,
);
$tvgPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasTvg'] === true,
);
$grouppedPls = array_filter(
$onlinePls,
static fn (array $pls) => count($pls['groups'] ?? []) > 0
);
$onlineCh = $offlineCh = $adultCh = [];
foreach ($onlinePls as $pls) {
$tmpOnline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === true,
);
$tmpOffline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === false,
);
$tmpAdult = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => in_array('adult', $ch['tags']),
);
$onlineCh = array_merge($onlineCh, $tmpOnline);
$offlineCh = array_merge($offlineCh, $tmpOffline);
$adultCh = array_merge($adultCh, $tmpAdult);
}
$replyText[] = '📊 *Статистика*'; $replyText[] = '📊 *Статистика*';
$replyText[] = ''; $replyText[] = '';
$replyText[] = '*Список изменён:* ' . $this->escape(ini()->updatedAt()); $replyText[] = '*Список изменён:* ' . $this->escape(ini()->updatedAt());
$replyText[] = ''; $replyText[] = '';
$replyText[] = '*Плейлистов:* ' . count(ini()->getPlaylists()); $replyText[] = '*Плейлистов:* ' . $stats['playlists']['all'];
$replyText[] = '🟢 Онлайн \- ' . count($onlinePls); $replyText[] = '🟢 Онлайн \- ' . $stats['playlists']['online'];
$replyText[] = '🔴 Оффлайн \- ' . count($offlinePls); $replyText[] = '🔴 Оффлайн \- ' . $stats['playlists']['offline'];
$replyText[] = '⚪ В очереди \- ' . count($unknownPls); $replyText[] = '⚪ В очереди \- ' . $stats['playlists']['unknown'];
$replyText[] = '🔞 Для взрослых \- ' . count($adultPls); $replyText[] = '🔞 Для взрослых \- ' . $stats['playlists']['adult'];
$replyText[] = '⏪ С перемоткой \- ' . count($catchupPls); $replyText[] = '⏪ С перемоткой \- ' . $stats['playlists']['hasCatchup'];
$replyText[] = '🗞️ С телепрограммой \- ' . count($tvgPls); $replyText[] = '🗞️ С телепрограммой \- ' . $stats['playlists']['hasTvg'];
$replyText[] = '🗂️ С группировкой каналов \- ' . count($grouppedPls); $replyText[] = '🗂️ С группировкой каналов \- ' . $stats['playlists']['groupped'];
$replyText[] = ''; $replyText[] = '';
$replyText[] = '*Каналов:* ' . count($allChannels); $replyText[] = '*Каналов:* ' . $stats['channels']['all'];
$replyText[] = '🟢 Онлайн \- ' . count($onlineCh); $replyText[] = '🟢 Онлайн \- ' . $stats['channels']['online'];
$replyText[] = '🔴 Оффлайн \- ' . count($offlineCh); $replyText[] = '🔴 Оффлайн \- ' . $stats['channels']['offline'];
$replyText[] = '🔞 Для взрослых \- ' . count($adultCh); $replyText[] = '🔞 Для взрослых \- ' . $stats['channels']['adult'];
$replyText[] = '';
$replyText[] = '*Самая свежая проверка* ';
$replyText[] = '🕔 ' . $this->escape($stats['playlists']['latest']['timeFmt']);
$replyText[] = $this->escape(base_url($stats['playlists']['latest']['code'] . '/details'));
$replyText[] = ''; $replyText[] = '';
$replyText[] = ''; $replyText[] = '';
@@ -433,7 +391,7 @@ class Bot
InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null, InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null,
): bool { ): bool {
try { try {
$this->bot->sendMessage( $this->api->sendMessage(
chatId: $this->update->getMessage()->getChat()->getId(), chatId: $this->update->getMessage()->getChat()->getId(),
text: $text, text: $text,
parseMode: 'MarkdownV2', parseMode: 'MarkdownV2',

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -31,56 +32,13 @@ class IniFile
* Считывает ini-файл и инициализирует плейлисты * Считывает ini-файл и инициализирует плейлисты
* *
* @return array * @return array
* @throws Exception
*/ */
public function load(): array public function load(): array
{ {
$filepath = config_path('playlists.ini'); $filepath = config_path('playlists.ini');
$ini = parse_ini_file($filepath, true); $this->playlists = parse_ini_file($filepath, true);
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath)); $this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
// сохраняем порядок
foreach (array_keys($ini) as $code) {
try {
$data = @redis()->get($code);
} catch (Throwable) {
$data = false;
}
if ($data === false) {
$raw = $ini[$code];
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Playlist #$code",
'description' => $raw['desc'] ?? null,
'url' => $raw['pls'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'onlineCount' => 0,
'offlineCount' => 0,
'checkedAt' => null,
];
} elseif (!isset($data['attributes'])) {
$data['attributes'] = [];
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg'])
|| !empty($data['attributes']['x-tvg-url']);
$data['tags'] = [];
foreach ($data['channels'] ?? [] as $channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
$this->playlists[$code] = $data;
}
return $this->playlists; return $this->playlists;
} }
@@ -90,9 +48,33 @@ class IniFile
* @return array[] * @return array[]
* @throws Exception * @throws Exception
*/ */
public function getPlaylists(): array public function getPlaylists(array $plsCodes = []): array
{ {
return $this->playlists ??= $this->load(); $playlists = [];
empty($this->playlists) && $this->load();
empty($plsCodes) && $plsCodes = array_keys($this->playlists);
$cached = array_combine($plsCodes, redis()->mget($plsCodes));
foreach ($cached as $code => $data) {
$playlists[$code] = $this->initPlaylist($code, $data);
}
return $playlists;
}
/**
* Возвращает плейлист по его коду
*
* @param string $code Код плейлиста
* @return array|null
* @throws PlaylistNotFoundException
* @throws Exception
*/
public function getPlaylist(string $code): ?array
{
empty($this->playlists) && $this->load();
$data = redis()->get($code);
return $this->initPlaylist($code, $data);
} }
/** /**
@@ -106,19 +88,103 @@ class IniFile
} }
/** /**
* Возвращает плейлист по его коду * Подготавливает данные о плейлисте в расширенном формате
* *
* @param string $code Код плейлиста * @param string $code
* @return array|null * @param array|false $data
* @return array
* @throws PlaylistNotFoundException * @throws PlaylistNotFoundException
* @throws Exception
*/ */
public function getPlaylist(string $code): ?array protected function initPlaylist(string $code, array|false $data): array
{ {
if (empty($this->playlists)) { if ($data === false) {
$this->load(); $raw = $this->playlists[$code]
?? throw new PlaylistNotFoundException($code);
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Плейлист #{$code}",
'description' => $raw['desc'] ?? null,
'url' => $raw['pls'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'checkedAt' => null,
];
} }
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code); // приколы golang
$data['attributes'] === null && $data['attributes'] = [];
$data['groups'] === null && $data['groups'] = [];
$data['channels'] === null && $data['channels'] = [];
$data['onlinePercent'] = 0;
$data['offlinePercent'] = 0;
if ($data['isOnline'] === true && count($data['channels']) > 0) {
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
$data['hasTokens'] = $this->hasTokens($data);
$data['tags'] = [];
foreach ($data['channels'] as &$channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
$channel['hasToken'] = $this->hasTokens($channel);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
return $data;
}
/**
* Проверяет наличие токенов в плейлисте
*
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
* в любой момент.
*
* @param array $data
* @return bool
*/
protected function hasTokens(array $data): bool
{
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
if (empty($string)) {
return false;
}
$badAttributes = [
// токены и ключи
'[?&]token=',
'[?&]drmreq=',
// логины
'[?&]u=',
'[?&]user=',
'[?&]username=',
// пароли
'[?&]p=',
'[?&]pwd=',
'[?&]password=',
// неизвестные
// 'free=true',
// 'uid=',
// 'c_uniq_tag=',
// 'rlkey=',
// '?s=',
// '&s=',
// '?q=',
// '&q=',
];
return array_any(
$badAttributes,
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
);
} }
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -30,11 +31,6 @@ final class Kernel
*/ */
public const string VERSION = '1.0.0'; public const string VERSION = '1.0.0';
/**
* @var Kernel
*/
private static Kernel $instance;
/** /**
* @var App * @var App
*/ */
@@ -55,6 +51,11 @@ final class Kernel
*/ */
protected ?Redis $cache = null; protected ?Redis $cache = null;
/**
* @var Kernel
*/
private static Kernel $instance;
/** /**
* Закрытый конструктор * Закрытый конструктор
* *
@@ -75,11 +76,73 @@ final class Kernel
* *
* @return Kernel * @return Kernel
*/ */
public static function instance(): Kernel public static function instance(): self
{ {
return self::$instance ??= new self(); return self::$instance ??= new self();
} }
/**
* Возвращает объект подключения к Redis
*
* @return Redis
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
*/
public function redis(): Redis
{
if (!empty($this->cache)) {
return $this->cache;
}
$options = [
'host' => $this->config['cache']['host'],
'port' => (int) $this->config['cache']['port'],
];
if (!empty($this->config['cache']['password'])) {
$options['auth'] = $this->config['cache']['password'];
}
$this->cache = new Redis($options);
$this->cache->select((int) $this->config['cache']['db']);
$this->cache->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
return $this->cache;
}
/**
* Возвращает значение из конфига
*
* @param string $key Ключ в формате "config.key"
* @param mixed|null $default Значение по умолчанию
* @return mixed
*/
public function config(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
return $this->config[$parts[0]][$parts[1]] ?? $default;
}
/**
* Возвращает объект приложения
*
* @return App
*/
public function app(): App
{
return $this->app;
}
/**
* Возвращает объект ini-файла
*
* @return IniFile
*/
public function ini(): IniFile
{
return $this->iniFile ??= new IniFile();
}
/** /**
* Загружает файл .env или .env.$env * Загружает файл .env или .env.$env
* *
@@ -88,12 +151,13 @@ final class Kernel
*/ */
protected function loadDotEnvFile(string $env = ''): array protected function loadDotEnvFile(string $env = ''): array
{ {
$filename = empty($env) ? '.env' : ".env.$env"; $filename = empty($env) ? '.env' : ".env.{$env}";
if (!file_exists(root_path($filename))) { if (!file_exists(root_path($filename))) {
return []; return [];
} }
$dotenv = Dotenv::createMutable(root_path(), $filename); $dotenv = Dotenv::createMutable(root_path(), $filename);
return $dotenv->safeLoad(); return $dotenv->safeLoad();
} }
@@ -138,7 +202,7 @@ final class Kernel
default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method)) default => throw new InvalidArgumentException(sprintf('Неверный HTTP метод %s', $method))
}; };
$definition = $this->app->$func($route['path'], $route['handler']); $definition = $this->app->{$func}($route['path'], $route['handler']);
} }
if (!empty($route['name'])) { if (!empty($route['name'])) {
@@ -163,65 +227,4 @@ final class Kernel
$twig->addExtension(new DebugExtension()); $twig->addExtension(new DebugExtension());
} }
} }
/**
* Возвращает объект подключения к Redis
*
* @return Redis
* @see https://github.com/phpredis/phpredis/?tab=readme-ov-file
*/
public function redis(): Redis
{
if (!empty($this->cache)) {
return $this->cache;
}
$options = [
'host' => $this->config['cache']['host'],
'port' => (int)$this->config['cache']['port'],
];
if (!empty($this->config['cache']['password'])) {
$options['auth'] = $this->config['cache']['password'];
}
$this->cache = new Redis($options);
$this->cache->select((int)$this->config['cache']['db']);
$this->cache->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
return $this->cache;
}
/**
* Возвращает значение из конфига
*
* @param string $key Ключ в формате "config.key"
* @param mixed|null $default Значение по умолчанию
* @return mixed
*/
public function config(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
return $this->config[$parts[0]][$parts[1]] ?? $default;
}
/**
* Возвращает объект приложения
*
* @return App
*/
public function app(): App
{
return $this->app;
}
/**
* Возвращает объект ini-файла
*
* @return IniFile
*/
public function ini(): IniFile
{
return $this->iniFile ??= new IniFile();
}
} }

View File

@@ -0,0 +1,133 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Core;
use Exception;
/**
* Обработчик команд бота
*/
class StatisticsService
{
/**
* @var array[]
*/
protected array $playlists = [];
protected array $channels = [];
public function __construct()
{
$this->playlists = ini()->getPlaylists();
$this->channels = $this->getAllChannels();
}
/**
* Обрабатывает команду /stats
*
* @return bool
* @throws Exception
*/
public function get(): array
{
return [
'playlists' => [
'all' => count($this->playlists),
'online' => count($this->getPlaylistsByField('isOnline', true)),
'offline' => count($this->getPlaylistsByField('isOnline', false)),
'unknown' => count($this->getPlaylistsByField('isOnline', null)),
'adult' => count($this->getPlaylistsByTag('adult')),
'hasCatchup' => count($this->getPlaylistsByField('hasCatchup', true)),
'hasTvg' => count($this->getPlaylistsByField('hasTvg', true)),
'groupped' => count($this->getPlaylistsWithGroups()),
'latest' => $this->getLatestPlaylist(),
],
'channels' => [
'all' => $this->getAllChannelsCount(),
'online' => count($this->getChannelsByField('isOnline', true)),
'offline' => count($this->getChannelsByField('isOnline', false)),
'adult' => count($this->getChannelsByTag('adult')),
],
];
}
protected function getPlaylistsByField(string $field, bool|int|string|null $value): array
{
return array_filter(
$this->playlists,
static fn (array $pls) => $pls[$field] === $value,
);
}
protected function getPlaylistsByTag(string $tag): array
{
return array_filter(
$this->playlists,
static fn (array $pls) => in_array($tag, $pls['tags']),
);
}
protected function getPlaylistsWithGroups(): array
{
return array_filter(
$this->playlists,
static fn (array $pls) => !empty($pls['groups']),
);
}
protected function getLatestPlaylist(): array
{
$e = array_combine(
array_column($this->playlists, 'code'),
array_column($this->playlists, 'checkedAt'),
);
$e = array_filter($e);
asort($e);
$latest = array_slice($e, 0, 1);
return [
'code' => array_first(array_keys($latest)),
'time' => $time = array_first($latest),
'timeFmt' => date('H:i:s d.m.Y', $time),
];
}
protected function getAllChannels(): array
{
$channels = [];
foreach ($this->playlists as $pls) {
$channels = array_merge($channels, $pls['channels']);
}
return $channels;
}
protected function getAllChannelsCount(): int
{
return count($this->channels);
}
protected function getChannelsByField(string $field, bool|int|string|null $value): array
{
return array_filter(
$this->channels,
static fn (array $channel) => $channel[$field] === $value,
);
}
protected function getChannelsByTag(string $tag): array
{
return array_filter(
$this->channels,
static fn (array $channel) => in_array($tag, $channel['tags']),
);
}
}

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -10,6 +11,7 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction; use Twig\TwigFunction;
/** /**
@@ -33,6 +35,17 @@ class TwigExtention extends AbstractExtension
]; ];
} }
/**
* @inheritDoc
* @noinspection PhpUnused
*/
public function getFilters(): array
{
return [
new TwigFilter('values', [$this, 'arrayValues']),
];
}
/** /**
* Возвращает значение из конфига * Возвращает значение из конфига
* *
@@ -66,17 +79,6 @@ class TwigExtention extends AbstractExtension
return base_url($path); return base_url($path);
} }
/**
* Возвращает зеркальный URL приложения
*
* @param string $path
* @return string
*/
public function mirrorUrl(string $path = ''): string
{
return mirror_url($path);
}
/** /**
* Проверячет существование файла * Проверячет существование файла
* *
@@ -97,6 +99,11 @@ class TwigExtention extends AbstractExtension
*/ */
public function toDate(?float $timestamp, string $format = 'd.m.Y H:i:s'): string public function toDate(?float $timestamp, string $format = 'd.m.Y H:i:s'): string
{ {
return $timestamp === null ? '(неизвестно)' : date($format, (int)$timestamp); return $timestamp === null ? '' : date($format, (int) $timestamp);
}
public function arrayValues($value, ...$args)
{
return is_array($value) ? array_values($value) : $value;
} }
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -9,9 +10,8 @@ declare(strict_types=1);
namespace App\Errors; namespace App\Errors;
use Psr\Http\Message\{ use Psr\Http\Message\ResponseInterface;
ResponseInterface, use Psr\Http\Message\ServerRequestInterface;
ServerRequestInterface};
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Slim\Handlers\ErrorHandler as SlimErrorHandler; use Slim\Handlers\ErrorHandler as SlimErrorHandler;
use Throwable; use Throwable;
@@ -21,33 +21,6 @@ use Throwable;
*/ */
class ErrorHandler extends SlimErrorHandler class ErrorHandler extends SlimErrorHandler
{ {
/**
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
*
* @param ServerRequestInterface $request
* @param Throwable $exception
* @param bool $displayErrorDetails
* @param bool $logErrors
* @param bool $logErrorDetails
* @param LoggerInterface|null $logger
* @return ResponseInterface
*/
public function __invoke(
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
?LoggerInterface $logger = null
): ResponseInterface {
$payload = $this->payload($exception, $displayErrorDetails);
$response = app()->getResponseFactory()->createResponse();
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response;
}
/** /**
* Возвращает структуру исключения для контекста * Возвращает структуру исключения для контекста
* *
@@ -63,7 +36,7 @@ class ErrorHandler extends SlimErrorHandler
'class' => $e::class, 'class' => $e::class,
'file' => $e->getFile(), 'file' => $e->getFile(),
'line' => $e->getLine(), 'line' => $e->getLine(),
'trace' => $e->getTrace() 'trace' => $e->getTrace(),
]; ];
return $result; return $result;
@@ -94,4 +67,31 @@ class ErrorHandler extends SlimErrorHandler
return $result; return $result;
} }
/**
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
*
* @param ServerRequestInterface $request
* @param Throwable $exception
* @param bool $displayErrorDetails
* @param bool $logErrors
* @param bool $logErrorDetails
* @param LoggerInterface|null $logger
* @return ResponseInterface
*/
public function __invoke(
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
?LoggerInterface $logger = null
): ResponseInterface {
$payload = $this->payload($exception, $displayErrorDetails);
$response = app()->getResponseFactory()->createResponse();
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response;
}
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -15,6 +16,6 @@ class InvalidTelegramSecretException extends Exception
{ {
public function __construct() public function __construct()
{ {
parent::__construct("Ошибка валидации запроса от Telegram Bot API"); parent::__construct('Ошибка валидации запроса от Telegram Bot API');
} }
} }

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -15,6 +16,6 @@ class PlaylistNotFoundException extends Exception
{ {
public function __construct(string $code) public function __construct(string $code)
{ {
parent::__construct("Плейлист '$code' не найден"); parent::__construct("Плейлист '{$code}' не найден");
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,16 @@
"telegram-bot/api": "^2.5", "telegram-bot/api": "^2.5",
"vlucas/phpdotenv": "^5.6" "vlucas/phpdotenv": "^5.6"
}, },
"require-dev": {
"fakerphp/faker": "^1.24.1",
"friendsofphp/php-cs-fixer": "^3.86.0",
"jetbrains/phpstorm-attributes": "^1.2",
"mockery/mockery": "^1.6.12",
"phpstan/phpstan": "^1.12.28",
"phpstan/phpstan-mockery": "^1.1.3",
"phpunit/phpunit": "^10.5.53",
"squizlabs/php_codesniffer": "^3.13.2"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"App\\": "app/" "App\\": "app/"

4492
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,21 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
declare(strict_types=1); declare(strict_types=1);
return [ return [
'title' => env('APP_TITLE', 'Агрегатор плейлистов'),
'base_url' => rtrim(trim(env('APP_URL', 'http://localhost:8080')), '/'), 'base_url' => rtrim(trim(env('APP_URL', 'http://localhost:8080')), '/'),
'mirror_url' => rtrim(trim(env('APP_URL_MIRROR') ?? '', '/')),
'debug' => bool(env('APP_DEBUG', false)), 'debug' => bool(env('APP_DEBUG', false)),
'env' => env('APP_ENV', env('IPTV_ENV', 'prod')), 'env' => env('APP_ENV', env('IPTV_ENV', 'prod')),
'title' => 'IPTV Плейлисты', 'timezone' => env('APP_TIMEZONE', 'GMT'),
'timezone' => env('APP_TIMEZONE', 'UTC'),
'page_size' => int(env('PAGE_SIZE', 10)), 'page_size' => int(env('PAGE_SIZE', 10)),
'repo_url' => env('REPO_URL', 'https://git.axenov.dev/IPTV'),
'pls_encodings' => [ 'pls_encodings' => [
'UTF-8', 'UTF-8',
'CP1251', 'CP1251',

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */

View File

@@ -1,7 +1,9 @@
<?php <?php
declare(strict_types=1);
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */
@@ -11,7 +13,6 @@ use App\Controllers\BotController;
use App\Controllers\WebController; use App\Controllers\WebController;
return [ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Web routes | Web routes
@@ -67,6 +68,24 @@ return [
'handler' => [ApiController::class, 'makeQrCode'], 'handler' => [ApiController::class, 'makeQrCode'],
'name' => 'api::makeQrCode', 'name' => 'api::makeQrCode',
], ],
[
'method' => 'GET',
'path' => '/api/version',
'handler' => [ApiController::class, 'version'],
'name' => 'api::version',
],
[
'method' => 'GET',
'path' => '/api/health',
'handler' => [ApiController::class, 'health'],
'name' => 'api::health',
],
[
'method' => 'GET',
'path' => '/api/stats',
'handler' => [ApiController::class, 'stats'],
'name' => 'api::stats',
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -81,4 +100,3 @@ return [
'name' => 'not-found', 'name' => 'not-found',
], ],
]; ];

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */

26
docker/dev/php.ini Normal file
View File

@@ -0,0 +1,26 @@
[PHP]
error_reporting = E_ALL & ~E_NOTICE & ~E_DEPRECATED
expose_php = Off
file_uploads = Off
max_execution_time=-1
memory_limit = 512M
[opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 30000
opcache.revalidate_freq = 0
opcache.jit_buffer_size = 64M
opcache.jit = tracing
[xdebug]
; https://xdebug.org/docs/all_settings
zend_extension = xdebug.so
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.trigger_value = go
xdebug.client_host = host.docker.internal
xdebug.REQUEST = *
xdebug.SESSION = *
xdebug.SERVER = *

22
docker/dev/www.conf Normal file
View File

@@ -0,0 +1,22 @@
[www]
user = www-data
group = www-data
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 50
pm.status_path = /status
ping.path = /ping
ping.response = pong
access.log = /var/log/php/$pool.access.log
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{milli}d %{kilo}M %C%%"
; chroot = /var/www
; chdir = /var/www
php_flag[display_errors] = on
php_admin_value[error_log] = /var/log/php/$pool.error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 512M
php_admin_value[error_reporting] = E_ALL & ~E_NOTICE & ~E_DEPRECATED

16
docker/prod/php.ini Normal file
View File

@@ -0,0 +1,16 @@
[PHP]
error_reporting = E_ALL & ~E_DEPRECATED
expose_php = Off
file_uploads = Off
memory_limit = 512M
; upload_max_filesize=10M
; post_max_size=10M
[opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 30000
opcache.revalidate_freq = 0
opcache.jit_buffer_size = 64M
opcache.jit = tracing

22
docker/prod/www.conf Normal file
View File

@@ -0,0 +1,22 @@
[www]
user = www-data
group = www-data
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 50
pm.status_path = /status
ping.path = /ping
ping.response = pong
access.log = /var/log/php/$pool.access.log
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{milli}d %{kilo}M %C%%"
; chroot = /var/www
; chdir = /var/www
php_flag[display_errors] = on
php_admin_value[error_log] = /var/log/php/$pool.error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 512M
php_admin_value[error_reporting] = E_ALL & ~E_NOTICE & ~E_DEPRECATED

432
linter Executable file
View File

@@ -0,0 +1,432 @@
#!/usr/bin/env bash
#
# Copyright (c) 2025, Антон Аксенов
# This file is part of m3u.su project
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
#
# shellcheck disable=SC2015
########################################################
# Служебные исходные переменные
########################################################
# имя контейнера
CONTAINER="iptv-web"
# команда для запуска
COMMAND="$1"; shift
# имена всех новых, скопированных, изменённых и перемещённых php-файлов относительно корня репозитория
FILES=($(git diff --name-only --diff-filter=ACMR HEAD 2>/dev/null | grep -e '.php$'))
# признак запуска гитом (хук)
IS_FROM_GIT="$(env | grep -c "GIT_EDITOR=:")"
# признак запуска изнутри контейнера
[[ -f /.dockerenv ]] && IS_FROM_CONTAINER=1 || IS_FROM_CONTAINER=0
# признак режима отладки
[[ $LINTER_DEBUG -gt 1 ]] && set -x
########################################################
# Ввод/вывод
########################################################
which tput > /dev/null 2>&1 && [ "$(tput -T"$TERM" colors)" -gt 8 ] && CAN_USE_COLORS=1 || CAN_USE_COLORS=0
LINTER_COLORS=${LINTER_COLORS:-$CAN_USE_COLORS}
[[ "$LINTER_COLORS" == 1 ]] && FRESET="$(tput sgr0)" || FRESET=''
[[ "$LINTER_COLORS" == 1 ]] && FBOLD="$(tput bold)" || FBOLD=''
[[ "$LINTER_COLORS" == 1 ]] && FDIM="$(tput dim)" || FDIM=''
[[ "$LINTER_COLORS" == 1 ]] && FRED="$(tput setaf 1)" || FRED=''
[[ "$LINTER_COLORS" == 1 ]] && FWHITE="$(tput setaf 7)" || FWHITE=''
[[ "$LINTER_COLORS" == 1 ]] && FGREEN="$(tput setaf 2)" || FGREEN=''
[[ "$LINTER_COLORS" == 1 ]] && FBRED="$(tput setab 1)" || FBRED=''
[[ "$LINTER_COLORS" == 1 ]] && FBYELLOW="$(tput setab 3)" || FBYELLOW=''
print() {
echo -e "$*${FRESET}"
}
debug() {
[[ "$LINTER_DEBUG" != 1 ]] && return
if [ "$2" ]; then
print "${FDIM}${FBOLD}${FRESET}${FDIM} ${FUNCNAME[1]:-?}():${BASH_LINENO:-?}\t$1 " >&2
else
print "${FDIM}${FBOLD}${FRESET}${FDIM} $*" >&2
fi
}
var_dump() {
debug "$1 = ${!1}"
}
title() {
print "${FBOLD}${FWHITE}$*${FRESET}"
}
success() {
print "${FBOLD}${FGREEN}$*${FRESET}"
}
warn() {
print "${FBOLD}${FBYELLOW}${FRED} Внимание! ${FRESET} $*${FRESET}"
}
error() {
print "${FBOLD}${FBRED}${FWHITE} Ошибка: ${FRESET}${FBOLD}${FRED} $*${FRESET}" >&2
}
########################################################
# Базовые хелперы
########################################################
# Проверяет существование функции с именем $1
is_function() {
declare -F "$1" > /dev/null
}
# Выполняет команду в контейнере
docker.exec() {
if [[ "$IS_FROM_GIT" == 1 ]]; then
cmd="docker exec -i $CONTAINER $*"
else
cmd="docker exec -it $CONTAINER $*"
fi
debug "Команда: $cmd"
$cmd
}
########################################################
# Хелперы для обработки команд
########################################################
# Выводит некоторую отладочную информацию
status() {
[[ "$LINTER_DEBUG" == 0 ]] && return
debug "* Внутри контейнера: ${IS_FROM_CONTAINER}"
debug "* Через git: ${IS_FROM_GIT}"
debug "* Изменённых файлов: ${#FILES[@]}"
}
# Запускает phpcs в контейнере
exec_phpcsniffer_pre() {
docker.exec vendor/bin/phpcs -p "$@"
}
# Запускает phpcbf в контейнере
exec_phpcsniffer_fix() {
docker.exec vendor/bin/phpcbf "$@"
}
# Запускает php-cs-fixer в контейнере
exec_phpcsfixer_pre() {
docker.exec vendor/bin/php-cs-fixer fix -vv --config=./.php-cs-fixer.php --using-cache=no --format=@auto --diff --dry-run "$@"
}
# Запускает php-cs-fixer в контейнере
exec_phpcsfixer_fix() {
docker.exec vendor/bin/php-cs-fixer fix -vv --config=./.php-cs-fixer.php --using-cache=no --format=@auto "$@"
}
# Запускает phpstan в контейнере
exec_phpstan() {
docker.exec vendor/bin/phpstan -v --memory-limit=1G analyse "$@"
}
# Запускает phpunit в контейнере
exec_phpunit() {
docker.exec vendor/bin/phpunit "$@"
}
########################################################
# Главные функции обработки команд
########################################################
# Устанавливает pre-commit git хук
install() {
status
[[ -d ./.git/hooks ]] || {
print "Не найден репозиторий '$(pwd)', пропускаю"
exit
}
cp -f "$0" ./.git/hooks/pre-commit && \
success "Pre-commit hook установлен" || \
error "Pre-commit хук НЕ установлен"
}
# Удаляет pre-commit git хук
remove() {
status
[[ -d ./.git/hooks ]] || {
print "Не найден репозиторий '$(pwd)', пропускаю"
exit
}
rm -f ./.git/hooks/pre-commit && \
success "Pre-commit hook удалён" || \
error "Pre-commit хук НЕ удалён"
}
# Запускает проверку код-стайла по всему проекту или только изменённым файлам
style() {
title "[php-cs-fixer] Запущена проверка код-стайла"
status
if [[ $# -gt 0 ]]; then
exec_phpcsfixer_pre "$@" || {
error "[php-cs-fixer] Проверка код-стайла завершена с ошибками!"
exit 1
}
else
exec_phpcsfixer_pre "${FILES[@]}" || {
error "[php-cs-fixer] Проверка код-стайла завершена с ошибками!"
exit 1
}
fi
success "[php-cs-fixer] Проверка код-стайла завершена успешно!"
}
# Запускает исправление код-стайла по всему проекту или только изменённым файлам
fix() {
title "[php-cs-fixer] Запущено исправление код-стайла"
status
if [[ $# -gt 0 ]]; then
exec_phpcsfixer_fix "$@" || {
error "[php-cs-fixer] Исправление код-стайла завершено с ошибками!"
exit 2
}
else
exec_phpcsfixer_fix "${FILES[@]}" || {
error "[php-cs-fixer] Исправление код-стайла завершено с ошибками!"
exit 2
}
fi
success "[php-cs-fixer] Исправление код-стайла завершено успешно!"
}
phpcs() {
title "[phpcs] Запущена проверка код-стайла"
status
if [[ $# -gt 0 ]]; then
exec_phpcsniffer_pre "$@" || {
error "[phpcs] Проверка код-стайла завершена с ошибками!"
exit 3
}
else
exec_phpcsniffer_pre "${FILES[@]}" || {
error "[phpcs] Проверка код-стайла завершена с ошибками!"
exit 3
}
fi
success "[phpcs] Проверка код-стайла завершена успешно!"
}
phpcbf() {
title "[phpcs] Запущено исправление код-стайла"
status
if [[ $# -gt 0 ]]; then
exec_phpcsniffer_fix "$@" || {
error "[phpcs] Исправление код-стайла завершено с ошибками!"
exit 4
}
else
exec_phpcsniffer_fix "${FILES[@]}"|| {
error "[phpcs] Исправление код-стайла завершено с ошибками!"
exit 4
}
fi
success "[phpcs] Исправление код-стайла завершено успешно!"
}
# Запускает статический анализ по всему проекту или только изменённым файлам
stan() {
title "[phpstan] Запуск статического анализа"
status
if [[ $# -gt 0 ]]; then
exec_phpstan "$@" || {
error "[phpstan] Статический анализ завершён с ошибками!"
exit 5
}
else
exec_phpstan "${FILES[@]}" || {
error "[phpstan] Статический анализ завершён с ошибками!"
exit 5
}
fi
success "[phpstan] Статический анализ завершён успешно!"
}
# Запускает выполнение тестов
tests() {
title "[phpunit] Запуск тестирования"
exec_phpunit "$@" || {
error "[phpunit] Тестирование завершено с ошибками!"
exit 6
}
success "[phpunit] Тестирование завершено успешно!"
}
lint() {
style
phpcs
stan
}
########################################################
# Команды справки
########################################################
help() {
print "${FBOLD}PHP-линтер"
is_function "help.$1" && help."$1" && exit
print "Использование:"
print " ./linter КОМАНДА [АРГУМЕНТЫ]"
print
print "Доступные КОМАНДЫ:"
print " h|help - вывести это сообщение"
print " i|install - установить как pre-commit git хук"
print " s|style - запустить проверку код-стайла (php-cs-fixer)"
print " f|fix - запустить исправление код-стайла (php-cs-fixer)"
print " p|phpcs - запустить проверку код-стайла (phpcs)"
print " c|phpcbf - запустить исправление код-стайла (phpcbf)"
print " a|stan - запустить статический анализ (phpstan)"
print " l|lint - запустить style + phpcs + stan"
print " t|tests - протестировать (phpunit)"
print
print "КОМАНДЫ 's', 'f', 'p', 'c' и 'a' могут принимать собственные АРГУМЕНТЫ php-cs-fixer, phpcs, phpcbf и phpstan соответственно."
print
print "Переменные окружения:"
print " LINTER_COLORS - принудительно включить (1) или выключить (0, по умолчанию) форматированный вывод"
print " LINTER_DEBUG - включить простую отладку (1), построчную (2) или выключить (0, по умолчанию)"
}
help.help() {
print "КОМАНДА: help"
print "Отображает справку по скрипту и его командам."
print
print "Использование:"
print " ./linter help [КОМАНДА]"
print
print "Если КОМАНДА не указана, будет отображена справка по скрипту со списком допустимых КОМАНД."
}
help.install() {
print "КОМАНДА: install"
print "Устанавливает linter в качестве pre-commit git-хука."
print
print "Использование:"
print " ./linter install"
print
print "Если в директории проекта нет репозитория, то установка будет пропущена без ошибки."
print "Перед коммитом будут выполнены команды lint и tests."
}
help.style() {
print "КОМАНДА: style"
print "Запускает php-cs-fixer для проверки код-стайла."
print "Поддерживает передачу собственных аргументов."
print
print "Использование:"
print " ./linter style [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://cs.symfony.com/doc/usage.html"
print "Если АРГУМЕНТЫ не указаны, будут анализированы только изменённые и новые php-файлы."
print "Если рабочее дерево git чистое, то инструмент отработает согласно своего конфига."
}
help.fix() {
print "КОМАНДА: fix"
print "Запускает php-cs-fixer для исправления код-стайла."
print "Поддерживает передачу собственных аргументов."
print
print "Использование:"
print " ./linter fix [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://cs.symfony.com/doc/usage.html"
print "Если АРГУМЕНТЫ не указаны, будут анализированы только изменённые и новые php-файлы."
print "Если рабочее дерево git чистое, то инструмент отработает согласно своего конфига."
}
help.phpcs() {
print "КОМАНДА: phpcs"
print "Запускает phpcs для проверки код-стайла."
print "Поддерживает передачу собственных аргументов."
print
print "Использование:"
print " ./linter phpcs [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage"
print "Если АРГУМЕНТЫ не указаны, будут анализированы только изменённые и новые php-файлы."
print "Если рабочее дерево git чистое, то инструмент отработает согласно своего конфига."
}
help.phpcbf() {
print "КОМАНДА: phpcbf"
print "Запускает phpcbf для исправления код-стайла."
print "Поддерживает передачу собственных аргументов."
print
print "Использование:"
print " ./linter phpcbf [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Fixing-Errors-Automatically#using-the-php-code-beautifier-and-fixer"
print "Если АРГУМЕНТЫ не указаны, будут анализированы только изменённые и новые php-файлы."
print "Если рабочее дерево git чистое, то инструмент отработает согласно своего конфига."
}
help.stan() {
print "КОМАНДА: stan"
print "Запускает phpstan для статического анализа."
print
print "Использование:"
print " ./linter stan [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://phpstan.org/user-guide/command-line-usage"
print "Если АРГУМЕНТЫ не указаны, будут анализированы только изменённые и новые php-файлы."
print "Если рабочее дерево git чистое, то инструмент отработает согласно своего конфига."
}
help.lint() {
print "КОМАНДА: lint"
print "Последовательно запускает команды 'style', 'phpcs' и 'stan'."
print "Не поддерживает передачу аргументов."
print
print "Использование:"
print " ./linter lint"
print
print "Для дополнительной информации см. './linter help' для соответствующей команды."
}
help.tests() {
print "КОМАНДА: tests"
print "Последовательно запускает phpunit для статического анализа."
print
print "Использование:"
print " ./linter tests [АРГУМЕНТЫ]"
print
print "Допустимые АРГУМЕНТЫ: https://docs.phpunit.de/en/10.5/textui.html#command-line-options"
}
########################################################
# Точка входа
########################################################
if [[ "$IS_FROM_GIT" -gt 0 ]]; then
status && lint && tests
success "Коммит разрешён!"
exit 0
fi
case "$COMMAND" in
h|help ) help "$1" ;;
i|install ) install ;;
r|remove ) remove ;;
s|style ) style "$@" ;;
f|fix ) fix "$@" ;;
p|phpcs ) phpcs "$@" ;;
c|phpcbf ) phpcbf "$@" ;;
a|stan ) stan "$@" ;;
l|lint ) lint ;;
t|tests ) tests "$@" ;;
* ) warn "неизвестная команда, вызываю help"; help ;;
esac

435
phpcs.xml Normal file
View File

@@ -0,0 +1,435 @@
<?xml version="1.0"?>
<!--
~ Copyright (c) 2025, Антон Аксенов
~ This file is part of m3u.su project
~ MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
-->
<!--
https://github.com/PHPCSStandards/PHP_CodeSniffer/tree/master/src/Standards
https://github.com/PHPCSStandards/PHPCSExtra?tab=readme-ov-file#sniffs
https://kalimah-apps.com/phpcs/docs/
https://github.com/slevomat/coding-standard
-->
<ruleset name="m3u.su Coding Standards">
<description>m3u.su Coding Standards</description>
<arg name="extensions" value="php"/>
<!--<arg name="report" value="summary"/>-->
<arg name="colors"/>
<arg name="parallel" value="5"/>
<arg name="tab-width" value="4"/>
<arg value="p"/>
<arg value="s"/>
<file>app/</file>
<file>config/</file>
<file>public/index.php</file>
<exclude-pattern>cache/</exclude-pattern>
<exclude-pattern>views/</exclude-pattern>
<exclude-pattern>vendor/</exclude-pattern>
<!-- ============================================================================================= -->
<!--<rule ref="Generic.Arrays.ArrayIndent">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Generic.Arrays.DisallowLongArraySyntax" />-->
<!--<rule ref="Generic.Classes.DuplicateClassName" />-->
<!--<rule ref="Generic.CodeAnalysis.AssignmentInCondition" />-->
<!--<rule ref="Generic.CodeAnalysis.EmptyPHPStatement" />-->
<!--<rule ref="Generic.CodeAnalysis.EmptyStatement" />-->
<!--<rule ref="Generic.CodeAnalysis.ForLoopShouldBeWhileLoop" />-->
<!--<rule ref="Generic.CodeAnalysis.JumbledIncrementer" />-->
<!--<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement" />-->
<!--<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier" />-->
<!--<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter" />-->
<!--<rule ref="Generic.CodeAnalysis.UselessOverridingMethod" />-->
<rule ref="Generic.ControlStructures.DisallowYodaConditions" />
<rule ref="Generic.ControlStructures.InlineControlStructure">
<properties>
<property name="error" value="false" />
</properties>
</rule>
<rule ref="Generic.Files.ByteOrderMark" />
<rule ref="Generic.Files.EndFileNewline" />
<!--<rule ref="Generic.Files.InlineHTML" />-->
<!--<rule ref="Generic.Files.LineEndings">-->
<!-- <properties>-->
<!-- <property name="eolChar" value="\n" />-->
<!-- </properties>-->
<!--</rule>-->
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120" />
<property name="absoluteLineLimit" value="120" />
<property name="ignoreComments" value="false" />
</properties>
</rule>
<rule ref="Generic.Files.OneClassPerFile" />
<rule ref="Generic.Files.OneInterfacePerFile" />
<rule ref="Generic.Files.OneObjectStructurePerFile" />
<rule ref="Generic.Files.OneTraitPerFile" />
<!--<rule ref="Generic.Formatting.NoSpaceAfterCast" />-->
<!--<rule ref="Generic.Functions.FunctionCallArgumentSpacing" />-->
<!--<rule ref="Generic.Functions.OpeningFunctionBraceBsdAllman">-->
<!-- <properties>-->
<!-- <property name="checkFunctions" value="false" />-->
<!-- <property name="checkClosures" value="false" />-->
<!-- </properties>-->
<!--</rule>-->
<rule ref="Generic.Metrics.CyclomaticComplexity">
<!-- https://ru.wikipedia.org/wiki/Цикломатическая_сложность -->
<properties>
<!-- TODO это высокие настройки, надо понижать -->
<property name="complexity" value="7" />
<property name="absoluteComplexity" value="10" />
</properties>
</rule>
<rule ref="Generic.Metrics.NestingLevel">
<properties>
<property name="nestingLevel" value="7" />
<property name="absoluteNestingLevel" value="10" />
</properties>
</rule>
<!--<rule ref="Generic.NamingConventions.CamelCapsFunctionName">-->
<!-- <properties>-->
<!-- <property name="strict" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<rule ref="Generic.NamingConventions.ConstructorName" />
<rule ref="Generic.NamingConventions.UpperCaseConstantName" />
<rule ref="Generic.PHP.BacktickOperator" />
<rule ref="Generic.PHP.CharacterBeforePHPOpeningTag" />
<rule ref="Generic.PHP.DeprecatedFunctions" />
<rule ref="Generic.PHP.DisallowAlternativePHPTags" />
<rule ref="Generic.PHP.DisallowRequestSuperglobal" />
<rule ref="Generic.PHP.DisallowShortOpenTag" />
<rule ref="Generic.PHP.DiscourageGoto" />
<rule ref="Generic.PHP.ForbiddenFunctions">
<properties>
<property name="forbiddenFunctions" type="array">
<element key="eval" value="null"/>
<element key="sizeof" value="count"/>
<element key="delete" value="unset"/>
<element key="join" value="implode"/>
<element key="create_function" value="null"/>
<element key="var_dump" value="null"/>
<element key="dd" value="null"/>
<element key="dump" value="null"/>
<element key="print" value="null"/>
<!--<element key="echo" value="null"/>-->
<!--<element key="print_r" value="null"/>-->
<!--<element key="var_export" value="null"/>-->
</property>
</properties>
</rule>
<rule ref="Generic.PHP.RequireStrictTypes" />
<rule ref="Generic.PHP.SAPIUsage" />
<rule ref="Generic.PHP.Syntax" />
<rule ref="Generic.VersionControl.GitMergeConflict" />
<!--<rule ref="Generic.WhiteSpace.ArbitraryParenthesesSpacing">-->
<!-- <properties>-->
<!-- <property name="ignoreNewlines" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<rule ref="Generic.WhiteSpace.DisallowTabIndent" />
<!-- ============================================================================================= -->
<!--<rule ref="PEAR.Classes.ClassDeclaration" />-->
<!--<rule ref="PEAR.Commenting.InlineComment" />-->
<!--<rule ref="PEAR.ControlStructures.ControlSignature">-->
<!-- <properties>-->
<!-- <property name="ignoreComments" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PEAR.ControlStructures.MultiLineCondition">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!-- &lt;!&ndash; первый операнд в мультистроковых ифах должен быть на новой строке &ndash;&gt;-->
<!-- <exclude name="PEAR.ControlStructures.MultiLineCondition.SpacingAfterOpenBrace" />-->
<!-- &lt;!&ndash; не каждый операнд в мультистроковых ифах должен начинаться с булева оператора &ndash;&gt;-->
<!-- <exclude name="PEAR.ControlStructures.MultiLineCondition.StartWithBoolean" />-->
<!--</rule>-->
<!--<rule ref="PEAR.Functions.FunctionCallSignature">-->
<!-- &lt;!&ndash;конфликтует с PEAR.WhiteSpace.ObjectOperatorIndent&ndash;&gt;-->
<!-- <properties>-->
<!-- <property name="allowMultipleArguments" value="true" />-->
<!-- <property name="indent" value="4" />-->
<!-- <property name="requiredSpacesAfterOpen" value="0" />-->
<!-- <property name="requiredSpacesBeforeClose" value="0" />-->
<!-- </properties>-->
<!-- &lt;!&ndash; ругается на открывающие скобки в одной строке &ndash;&gt;-->
<!-- <exclude name="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket" />-->
<!-- &lt;!&ndash; ругается на закрывающие скобки в одной строке &ndash;&gt;-->
<!-- <exclude name="PEAR.Functions.FunctionCallSignature.CloseBracketLine" />-->
<!--</rule>-->
<!--<rule ref="PEAR.Functions.FunctionDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PEAR.Functions.ValidDefaultValue" />-->
<!--<rule ref="PEAR.WhiteSpace.ObjectOperatorIndent">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- <property name="multilevel" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PEAR.WhiteSpace.ScopeClosingBrace">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PEAR.WhiteSpace.ScopeIndent">-->
<!-- &lt;!&ndash; конфликтует с Generic.WhiteSpace.ScopeIndent &ndash;&gt;-->
<!-- <properties>-->
<!-- <property name="exact" value="false" />-->
<!-- <property name="tabIndent" value="false" />-->
<!-- <property name="ignoreIndentationTokens" type="array" value="T_COMMENT,T_DOC_COMMENT_OPEN_TAG,T_CASE" />-->
<!-- </properties>-->
<!--</rule>-->
<!-- ============================================================================================= -->
<!--<rule ref="PSR1.Classes.ClassDeclaration" />-->
<!--<rule ref="PSR1.Files.SideEffects" />-->
<!--<rule ref="PSR1.Methods.CamelCapsMethodName" />-->
<!-- ============================================================================================= -->
<!--<rule ref="PSR2.Classes.ClassDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR2.Classes.PropertyDeclaration" />-->
<rule ref="PSR2.ControlStructures.ControlStructureSpacing">
<!-- первый операнд в мультистроковых ифах должен быть на новой строке -->
<exclude name="PSR2.ControlStructures.ControlStructureSpacing.SpacingAfterOpenBrace" />
</rule>
<!--<rule ref="PSR2.ControlStructures.ElseIfDeclaration" />-->
<!--<rule ref="PSR2.ControlStructures.SwitchDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR2.Files.ClosingTag" />-->
<!--<rule ref="PSR2.Files.EndFileNewline" />-->
<!--<rule ref="PSR2.Methods.FunctionCallSignature">-->
<!-- <properties>-->
<!-- <property name="allowMultipleArguments" value="true" />-->
<!-- <property name="indent" value="4" />-->
<!-- <property name="requiredSpacesAfterOpen" value="0" />-->
<!-- <property name="requiredSpacesBeforeClose" value="0" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR2.Methods.FunctionClosingBrace" />-->
<!--<rule ref="PSR2.Methods.MethodDeclaration" />-->
<!--<rule ref="PSR2.Namespaces.NamespaceDeclaration" />-->
<!--<rule ref="PSR2.Namespaces.UseDeclaration" />-->
<!-- ============================================================================================= -->
<!--<rule ref="PSR12.Classes.AnonClassDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR12.Classes.ClassInstantiation" />-->
<!--<rule ref="PSR12.Classes.ClosingBrace" />-->
<!--<rule ref="PSR12.Classes.OpeningBraceSpace" />-->
<!--<rule ref="PSR12.ControlStructures.BooleanOperatorPlacement">-->
<!-- <properties>-->
<!-- <property name="allowOnly" value="first" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR12.ControlStructures.ControlStructureSpacing">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="PSR12.Files.DeclareStatement" />-->
<!--<rule ref="PSR12.Files.ImportStatement" />-->
<!--<rule ref="PSR12.Files.OpenTag" />-->
<!--<rule ref="PSR12.Functions.NullableTypeDeclaration" />-->
<!--<rule ref="PSR12.Functions.ReturnTypeDeclaration" />-->
<!--<rule ref="PSR12.Keywords.ShortFormTypeKeywords" />-->
<!--<rule ref="PSR12.Operators.OperatorSpacing" />-->
<!--<rule ref="PSR12.Properties.ConstantVisibility" />-->
<!--<rule ref="PSR12.Traits.UseDeclaration" />-->
<!-- ============================================================================================= -->
<!--<rule ref="Squiz.Arrays.ArrayBracketSpacing" />-->
<!--<rule ref="Squiz.Classes.ClassDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.Classes.ClassFileName" />-->
<!--<rule ref="Squiz.Classes.LowercaseClassKeywords" />-->
<!--<rule ref="Squiz.Classes.SelfMemberReference" />-->
<!--<rule ref="Squiz.Classes.ValidClassName" />-->
<!--<rule ref="Squiz.Commenting.DocCommentAlignment" />-->
<!--<rule ref="Squiz.Commenting.EmptyCatchComment" />-->
<!--<rule ref="Squiz.Commenting.FunctionCommentThrowTag">-->
<!-- &lt;!&ndash; не умеет считать выбросы исключений из глубины &ndash;&gt;-->
<!-- <exclude name="Squiz.Commenting.FunctionCommentThrowTag.WrongNumber" />-->
<!--</rule>-->
<!--<rule ref="Squiz.Commenting.VariableComment">-->
<!-- &lt;!&ndash; требует длинное название типа (integer, boolean, ...) вместо нормального (int, bool, ..) &ndash;&gt;-->
<!-- <exclude name="Squiz.Commenting.VariableComment.IncorrectVarType" />-->
<!-- &lt;!&ndash; не понимает наличие аннотации между докблоком и декларацией &ndash;&gt;-->
<!-- <exclude name="Squiz.Commenting.VariableComment.WrongStyle" />-->
<!-- <exclude name="Squiz.WhiteSpace.MemberVarSpacing.Incorrect" />-->
<!--</rule>-->
<!--<rule ref="Squiz.ControlStructures.ControlSignature">-->
<!-- <properties>-->
<!-- <property name="requiredSpacesBeforeColon" value="0" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.ControlStructures.ForEachLoopDeclaration" />-->
<!--<rule ref="Squiz.ControlStructures.ForLoopDeclaration">-->
<!-- <properties>-->
<!-- <property name="ignoreNewlines" value="false" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.ControlStructures.LowercaseDeclaration" />-->
<!--<rule ref="Squiz.ControlStructures.SwitchDeclaration">-->
<!-- <properties>-->
<!-- <property name="indent" value="4" />-->
<!-- </properties>-->
<!-- &lt;!&ndash; требует выход из кейса на 1 уровне с кейсом &ndash;&gt;-->
<!-- <exclude name="Squiz.ControlStructures.SwitchDeclaration.BreakIndent" />-->
<!--</rule>-->
<!--<rule ref="Squiz.Functions.FunctionDeclaration">-->
<!-- <properties>-->
<!-- <property name="ignoreComments" value="false" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing">-->
<!-- <properties>-->
<!-- <property name="equalsSpacing" value="1" />-->
<!-- <property name="requiredSpacesAfterOpen" value="0" />-->
<!-- <property name="requiredSpacesBeforeClose" value="0" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.Functions.FunctionDuplicateArgument" />-->
<!--<rule ref="Squiz.Functions.GlobalFunction" />-->
<!--<rule ref="Squiz.Functions.LowercaseFunctionKeywords" />-->
<!--<rule ref="Squiz.Functions.MultiLineFunctionDeclaration" />-->
<!--<rule ref="Squiz.NamingConventions.ValidFunctionName">-->
<!-- &lt;!&ndash; требует _ в начале приватных методов &ndash;&gt;-->
<!-- <exclude name="Squiz.NamingConventions.ValidFunctionName.PrivateNoUnderscore" />-->
<!--</rule>-->
<!--<rule ref="Squiz.Objects.DisallowObjectStringIndex" />-->
<!--<rule ref="Squiz.Objects.ObjectMemberComma" />-->
<!--<rule ref="Squiz.Operators.IncrementDecrementUsage" />-->
<!--<rule ref="Squiz.Operators.ValidLogicalOperators" />-->
<!--TODO проблема с комментариями //, которые занимают полную строку -->
<!-- <rule ref="Squiz.PHP.CommentedOutCode" /> -->
<!--<rule ref="Squiz.PHP.DiscouragedFunctions">-->
<!-- <properties>-->
<!-- <property name="error" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.PHP.Eval" />-->
<!--<rule ref="Squiz.PHP.GlobalKeyword" />-->
<!--<rule ref="Squiz.PHP.InnerFunctions" />-->
<!--<rule ref="Squiz.PHP.LowercasePHPFunctions" />-->
<!--<rule ref="Squiz.PHP.NonExecutableCode" />-->
<!--<rule ref="Squiz.Scope.MemberVarScope" />-->
<!--<rule ref="Squiz.Scope.MethodScope" />-->
<!--<rule ref="Squiz.Scope.StaticThisUsage" />-->
<!--<rule ref="Squiz.Strings.EchoedStrings" />-->
<!--<rule ref="Squiz.WhiteSpace.CastSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.ControlStructureSpacing">-->
<!-- &lt;!&ndash; первый операнд в мультистроковых ифах должен быть на новой строке &ndash;&gt;-->
<!-- <exclude name="Squiz.WhiteSpace.ControlStructureSpacing.SpacingAfterOpenBrace" />-->
<!--</rule>-->
<!--<rule ref="Squiz.WhiteSpace.FunctionOpeningBraceSpace" />-->
<!--<rule ref="Squiz.WhiteSpace.FunctionSpacing">-->
<!-- <properties>-->
<!-- <property name="spacing" value="1" />-->
<!-- <property name="spacingBeforeFirst" value="0" />-->
<!-- <property name="spacingAfterLast" value="0" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.WhiteSpace.LanguageConstructSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.LogicalOperatorSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.MemberVarSpacing">-->
<!-- <properties>-->
<!-- <property name="spacing" value="1" />-->
<!-- <property name="spacingBeforeFirst" value="0" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.WhiteSpace.ObjectOperatorSpacing">-->
<!-- <properties>-->
<!-- <property name="ignoreNewlines" value="true" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.WhiteSpace.OperatorSpacing">-->
<!-- <properties>-->
<!-- <property name="ignoreNewlines" value="true" />-->
<!-- <property name="ignoreSpacingBeforeAssignments" value="false" />-->
<!-- </properties>-->
<!--</rule>-->
<!--<rule ref="Squiz.WhiteSpace.PropertyLabelSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.ScopeClosingBrace" />-->
<!--<rule ref="Squiz.WhiteSpace.ScopeKeywordSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.SemicolonSpacing" />-->
<!--<rule ref="Squiz.WhiteSpace.SuperfluousWhitespace">-->
<!-- <properties>-->
<!-- <property name="ignoreBlankLines" value="false" />-->
<!-- </properties>-->
<!--</rule>-->
<!-- ============================================================================================= -->
</ruleset>

48
phpstan.neon Normal file
View File

@@ -0,0 +1,48 @@
includes:
- ./vendor/phpstan/phpstan-mockery/extension.neon
parameters:
# https://phpstan.org/config-reference
level: 8
polluteScopeWithLoopInitialAssignments: true
polluteScopeWithAlwaysIterableForeach: true
# polluteScopeWithBlock: true # v2+
checkExplicitMixedMissingReturn: true
checkFunctionNameCase: true
checkInternalClassCaseSensitivity: true
reportMaybesInMethodSignatures: false # при true перегиб для объявленных в интерфейсах шаблонов
reportMaybesInPropertyPhpDocTypes: false # при true перегиб для объявленных в интерфейсах шаблонов
reportStaticMethodSignatures: true
checkTooWideReturnTypesInProtectedAndPublicMethods: false
checkUninitializedProperties: false
checkDynamicProperties: false
rememberPossiblyImpureFunctionValues: true
# checkBenevolentUnionTypes: true # перегиб для union-types
reportPossiblyNonexistentGeneralArrayOffset: false
reportPossiblyNonexistentConstantArrayOffset: false
reportAlwaysTrueInLastCondition: true
reportWrongPhpDocTypeInVarTag: true
reportAnyTypeWideningInVarTag: true
checkMissingOverrideMethodAttribute: true
treatPhpDocTypesAsCertain: false
reportUnmatchedIgnoredErrors: false
checkMissingCallableSignature: true
parallel:
jobSize: 5
maximumNumberOfProcesses: 16
minimumNumberOfJobsPerProcess: 1
paths:
- app/
- config/
- public/index.php
excludePaths:
- cache/
- views/
- vendor/
ignoreErrors:
# https://phpstan.org/user-guide/ignoring-errors
# https://phpstan.org/error-identifiers
# требует явно расписывать все итерируемые типы, структуры полей и т.д.
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано
# - identifier: missingType.iterableValue

43
phpunit.xml Normal file
View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright (c) 2025, Антон Аксенов
~ This file is part of m3u.su project
~ MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<!--<testsuites>-->
<!-- <testsuite name="Controllers">-->
<!-- <directory>tests/Http/Controllers</directory>-->
<!-- </testsuite>-->
<!--</testsuites>-->
<source>
<include>
<directory>app/</directory>
<directory>config/</directory>
<directory>public/</directory>
</include>
<exclude>
<directory>cache/</directory>
<directory>views/</directory>
<directory>vendor/</directory>
</exclude>
</source>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,7 +1,8 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface * This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE * MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/ */

View File

@@ -1,6 +1,6 @@
{########################################################################### {###########################################################################
# Copyright (c) 2025, Антон Аксенов # Copyright (c) 2025, Антон Аксенов
# This file is part of iptv.axenov.dev web interface # This file is part of m3u.su project
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE # MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
###########################################################################} ###########################################################################}
@@ -8,7 +8,7 @@
{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %} {% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %}
{% block metadescription %}Смотреть бесплатный самообновляемый плейлист {{ playlist.name }}, посмотреть статус плейлиста {{ playlist.description }}{% endblock %} {% block metadescription %}Смотреть бесплатный самообновляемый плейлист {{ playlist.name }}, проверить статус, {{ playlist.description }}{% endblock %}
{% block metakeywords %}самообновляемый,бесплатный,iptv-плейлист,iptv,плейлист{% if (playlist.groups|length > 1) %}{% for group in playlist.groups %},{{ group.name|lower }}{% endfor %}{% endif %},{{ playlist.tags|join(',') }}{% endblock %} {% block metakeywords %}самообновляемый,бесплатный,iptv-плейлист,iptv,плейлист{% if (playlist.groups|length > 1) %}{% for group in playlist.groups %},{{ group.name|lower }}{% endfor %}{% endif %},{{ playlist.tags|join(',') }}{% endblock %}
@@ -31,11 +31,6 @@
{% block header %} {% block header %}
<h2>О плейлисте: {{ playlist.name }}</h2> <h2>О плейлисте: {{ playlist.name }}</h2>
{% if playlist.isOnline is same as(false) %}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{ playlist.content }}
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -49,7 +44,7 @@
data-bs-toggle="tab" data-bs-toggle="tab"
data-bs-target="#tab-data" data-bs-target="#tab-data"
> >
<ion-icon name="radio-outline"></ion-icon>&nbsp;Основные данные <ion-icon name="radio-outline"></ion-icon>&nbsp;Основные&nbsp;данные
</a> </a>
</li> </li>
<li class="nav-item small"> <li class="nav-item small">
@@ -59,7 +54,17 @@
data-bs-toggle="tab" data-bs-toggle="tab"
data-bs-target="#tab-raw" data-bs-target="#tab-raw"
> >
<ion-icon name="document-text-outline"></ion-icon>&nbsp;Исходный текст <ion-icon name="document-text-outline"></ion-icon>&nbsp;Исходный&nbsp;текст
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-abuse"
data-bs-toggle="tab"
data-bs-target="#tab-abuse"
>
<ion-icon name="wallet-outline"></ion-icon>&nbsp;Правообладателям
</a> </a>
</li> </li>
</ul> </ul>
@@ -70,18 +75,19 @@
<tr> <tr>
<th class="w-25" scope="row">Код</th> <th class="w-25" scope="row">Код</th>
<th class="text-break"> <th class="text-break">
{% if playlist.isOnline is same as(true) %} <span class="pe-3 font-monospace">{{ playlist.code }}</span>
<span class="font-monospace text-success">{{ playlist.code }}</span> {% if playlist.isOnline is same as (true) %}
<span class="badge small text-dark bg-success">онлайн</span> <span class="cursor-help badge small text-dark bg-success"
{% elseif playlist.isOnline is same as(false) %} title="Вероятно, работает"
<span class="font-monospace text-danger">{{ playlist.code }}</span> >online</span>
<span class="badge small text-dark bg-danger">оффлайн</span> {% elseif playlist.isOnline is same as (false) %}
{% elseif playlist.isOnline is same as(null) %} <span class="cursor-help badge small text-dark bg-danger"
<span class="font-monospace">{{ playlist.code }}</span> title="Вероятно, не работает"
<span class="badge small text-dark bg-secondary" title="Не проверялся">unknown</span> >offline</span>
{% endif %} {% elseif playlist.isOnline is same as (null) %}
{% if "adult" in playlist.tags %} <span class="cursor-help badge small text-dark bg-secondary"
<span class="badge small bg-warning text-dark" title="Есть каналы для взрослых!">18+</span> title="Не проверялся"
>unknown</span>
{% endif %} {% endif %}
</th> </th>
</tr> </tr>
@@ -90,13 +96,15 @@
<td class="text-break"><p class="mb-0">{{ playlist.description }}</p></td> <td class="text-break"><p class="mb-0">{{ playlist.description }}</p></td>
</tr> </tr>
<tr> <tr>
<th scope="row">Ccылка для ТВ</th> <th scope="row">Короткая ссылка</th>
<td> <td>
<b onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')" <span onclick="copyPlaylistUrl('{{ playlist.code }}')"
data-bs-toggle="tooltip" class="cursor-pointer"
data-bs-placement="top" title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена" >
class="font-monospace cursor-pointer text-break">{{ mirror_url(playlist.code) }}</b> <b class="cursor-pointer font-monospace text-break">{{ base_url(playlist.code) }}</b>
<ion-icon name="copy-outline"></ion-icon>
</span>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -106,16 +114,37 @@
<tr> <tr>
<th scope="row">Наполнение</th> <th scope="row">Наполнение</th>
<td class="text-break"> <td class="text-break">
<ion-icon name="folder-open-outline"></ion-icon>&nbsp;группы:&nbsp;{{ playlist.groups|length }}, {% if playlist.isOnline is same as (true) %}
<ion-icon name="videocam-outline"></ion-icon>&nbsp;каналы:&nbsp;{{ playlist.channels|length }} {% if playlist.hasTokens is same as (true) %}
(<span class="text-success">{{ playlist.onlineCount }}</span>&nbsp;+&nbsp;<span class="text-danger">{{ playlist.offlineCount }}</span>) <span class="cursor-help badge bg-info text-dark">
<ion-icon name="paw"></ion-icon>
</span>&nbsp;могут быть нестабильные каналы<br>
{% endif %}
{% if "adult" in playlist.tags %}
<span class="cursor-help badge small bg-warning text-dark">18+</span>&nbsp;есть каналы для взрослых<br>
{% endif %}
<ion-icon name="folder-open-outline"></ion-icon>&nbsp;группы: {{ playlist.groups|length }}<br>
<ion-icon name="videocam-outline"></ion-icon>&nbsp;каналы:
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
{{ playlist.onlineCount }} ({{ playlist.onlinePercent }}%)
</span>
+
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
{{ playlist.offlineCount }} ({{ playlist.offlinePercent }}%)
</span>
= {{ playlist.channels|length }}
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Возможности</th> <th scope="row">Возможности</th>
<td class="text-break"> <td class="text-break">
<ion-icon name="newspaper-outline"></ion-icon>&nbsp;Программа передач:&nbsp;{{ playlist.hasTvg ? 'есть' : 'нет' }}<br> {% if playlist.isOnline is same as (true) %}
<ion-icon name="play-back"></ion-icon>&nbsp;Перемотка (архив):&nbsp;{{ playlist.hasCatchup ? 'есть' : 'нет' }} <ion-icon name="newspaper-outline"></ion-icon>&nbsp;Программа передач:&nbsp;{{ playlist.hasTvg ? 'есть' : 'нет' }}<br>
<ion-icon name="play-back"></ion-icon>&nbsp;Перемотка&nbsp;(архив):&nbsp;{{ playlist.hasCatchup ? 'есть' : 'нет' }}
{% endif %}
</td> </td>
</tr> </tr>
<tr class="text-secondary"> <tr class="text-secondary">
@@ -130,7 +159,7 @@
</span> </span>
</td> </td>
</tr> </tr>
{% if playlist.isOnline is same as(false) %} {% if playlist.isOnline is same as (false) %}
<tr class="text-secondary"> <tr class="text-secondary">
<th class="w-25" scope="row">Ошибка проверки</th> <th class="w-25" scope="row">Ошибка проверки</th>
<td class="text-break">{{ playlist.content }}</td> <td class="text-break">{{ playlist.content }}</td>
@@ -186,6 +215,35 @@
readonly readonly
>{{ playlist.content }}</textarea> >{{ playlist.content }}</textarea>
</div> </div>
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
<p class="my-3">
Данные, представленные на данной странице, получены автоматически из открыто доступных в
интернете IPTV-плейлистов, опубликованных третьими лицами.
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
данные".
</p>
<p class="my-3">
Сервис {{ base_url() }} не размещает и не транслирует медиаконтент, не создаёт, не призывает
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
телепрограмм.
</p>
<p class="my-3">
Подробности о проекте и о том, как здесь оказались объекты ваших прав,
<a href="{{ base_url('docs') }}" target="_blank">читайте здесь</a>.
</p>
<p class="my-3">
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
формируется исключительно путём обработки содержимого самого плейлиста.
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
</p>
<p class="my-3">
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
Если вы являетесь правообладателем и считаете, что сведения на этой странице затрагивают ваши права, вы можете направить конфиденциальное уведомление на адрес abuse@m3u.su.
</p>
<p class="my-3">
Плейлисты, нарушающие законодательство, удаляются с сайта окончательно по факту обращения от правообладателя.
</p>
</div>
</div> </div>
</div> </div>
@@ -195,12 +253,10 @@
{% if (playlist.groups|length > 1) %} {% if (playlist.groups|length > 1) %}
<div class="row my-3"> <div class="row my-3">
<div class="col-12"> <div class="col-12">
{% if (playlist.channels|length >= 500) %} {% if (playlist.channels|length > 100) %}
<div class="alert alert-warning small" role="alert" id="toomuchalert"> <div class="alert alert-warning small" role="alert" id="chListLoading">
<div class="spinner-border text-success spinner-border-sm" role="status"> <div class="spinner-border text-success spinner-border-sm" role="status"></div>
<span class="visually-hidden">Загрузка...</span> Загрузка...
</div>
В плейлисте очень много каналов. На загрузку их списка и логотипов потребуется некоторое время.
</div> </div>
{% endif %} {% endif %}
<div class="input-group"> <div class="input-group">
@@ -230,7 +286,7 @@
<div class="input-group"> <div class="input-group">
<input type="text" <input type="text"
id="search-field" id="search-field"
class="form-control form-control-sm border-secondary bg-dark text-light fuzzy-search" class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
placeholder="Поиск каналов..." placeholder="Поиск каналов..."
title="Начни вводить название" title="Начни вводить название"
/> />
@@ -261,7 +317,7 @@
for="chfOnline" for="chfOnline"
title="Выбрать только онлайн каналы" title="Выбрать только онлайн каналы"
> >
<ion-icon name="radio-button-on-outline"></ion-icon> <ion-icon name="radio-button-on-outline"></ion-icon>{{ playlist.onlineCount }}
</label> </label>
<input type="radio" <input type="radio"
@@ -275,7 +331,7 @@
for="chfOffline" for="chfOffline"
title="Выбрать только оффлайн каналы" title="Выбрать только оффлайн каналы"
> >
<ion-icon name="radio-button-on-outline"></ion-icon> <ion-icon name="radio-button-on-outline"></ion-icon>{{ playlist.offlineCount }}
</label> </label>
<button type="button" <button type="button"
@@ -289,17 +345,17 @@
<div class="my-3"> <div class="my-3">
{% for tag in playlist.tags %} {% for tag in playlist.tags %}
<input type="checkbox" <input type="checkbox"
class="btn-check" class="btn-check"
id="btn-tag-{{ tag }}" id="btn-tag-{{ tag }}"
data-tag="{{ tag }}" data-tag="{{ tag }}"
autocomplete="off" autocomplete="off"
onclick="updateFilter()" onclick="updateFilter()"
> >
<label class="badge btn btn-sm btn-outline-secondary rounded-pill" <label class="badge btn btn-sm btn-outline-secondary rounded-pill"
for="btn-tag-{{ tag }}" for="btn-tag-{{ tag }}"
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр" title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
>#{{ tag }}</label> >#{{ tag }}</label>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -308,94 +364,103 @@
<div class="chlist-table overflow-auto"> <div class="chlist-table overflow-auto">
<table id="chlist" class="table table-dark table-hover small"> <table id="chlist" class="table table-dark table-hover small">
<tbody class="list"> <tbody class="list">
{% for channel in playlist.channels %}
<tr class="chrow"
data-id="{{ channel.id }}"
data-group="{{ channel.groupId ?? 'all' }}"
data-online="{{ channel.isOnline ? 1 : 0 }}"
data-chtags="{{ channel.tags|join('|') }}"
title="&#010;HTTP: {{ channel.status ?: '(неизвестно)' }}&#010;Error: {{ channel.error ?: '(нет)' }}"
>
<td class="chindex">{{ loop.index }}</td>
<td class="chlogo text-center">
{% if (channel.attributes['tvg-logo']) %}
<img class="tvg-logo"
alt="Логотип канала '{{ channel.title }}'"
title="Логотип канала '{{ channel.title }}'"
src="{{ channel.attributes['tvg-logo'] }}"
onerror="setDefaultLogo(this)"
/>
{% else %}
<img class="tvg-logo"
alt="Нет логотипа для канала '{{ channel.title }}'"
title="Нет логотипа для канала '{{ channel.title }}'"
src="/no-tvg-logo.png"
/>
{% endif %}
</td>
<td class="text-break">
<ion-icon name="radio-button-on-outline"
{% if (channel.isOnline) %}
class="me-1 text-success"
title="Состояние: онлайн"
{% else %}
class="me-1 text-danger"
title="Состояние: оффлайн"
{% endif %}
></ion-icon>{% if "adult" in channel.tags %}
<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>
{% endif %}<span class="chname">{{ channel.title }}</span>
<div class="text-secondary small">
{% if (channel.attributes['tvg-id']) %}
<div title="tvg-id">
<ion-icon name="star-outline" class="me-1"></ion-icon>&nbsp;{{ channel.attributes['tvg-id'] }}
</div>
{% endif %}
{% if (channel.contentType != null) %}
<div title="MIME type">
<ion-icon name="eye-outline" class="me-1"></ion-icon>&nbsp;{{ channel.contentType }}
</div>
{% endif %}
{% if channel.tags|length > 0 %}
<ion-icon name="pricetag-outline" class="me-1"></ion-icon>
{% for tag in channel.tags %}
<span class="chtag">#{{ tag }}</span>
{% endfor %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% elseif playlist.isOnline is same as (false) %}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{ playlist.content }}
</div>
{% elseif playlist.isOnline is same as (null) %}
<div class="alert alert-warning small" role="alert" id="chListLoading">
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
<br><br>
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{% if playlist.isOnline is same as (true) %}
<script src="/js/list.min.js"></script> <script src="/js/list.min.js"></script>
<script> <script>
const options = { function getChannelTemplate(channel) {
valueNames: [ const httpCode = channel.status ?? '(неизвестно)'
'chname', const errText = !!channel.error
{data: ['online', 'group', 'tag', 'chtags']} ? channel.error.replace(/&/g, '&amp;')
], .replace(/</g, '&lt;')
}; .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
: '(нет)'
const list = new List('chlist', options) const logoUrl = channel.attributes['tvg-logo'] ?? '/no-tvg-logo.png'
const logoHint = !!channel.attributes['tvg-logo']
? `Логотип канала '${channel.title}'`
: `Нет логотипа для канала '${channel.title}'`
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
const statusIconHint = channel.isOnline
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
const adultIcon = channel.tags.indexOf('adult') !== -1
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
: ''
const pawIcon = channel.hasToken
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
: ''
const tvgId = !!channel.attributes['tvg-id']
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon>&nbsp;${channel.attributes['tvg-id']}</div>`
: ``
const mimeType = !!channel.contentType
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon>&nbsp;${channel.contentType}</div>`
: ``
const tags = channel.tags.length > 0
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon>&nbsp;`
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
: ``
return `<tr class="chrow" title="&#010;HTTP: ${httpCode}&#010;Error: ${errText}">
<td class="chlogo text-center">
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
</td>
<td class="text-break">
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
${adultIcon}
${pawIcon}
${channel.title}
<div class="text-secondary small">
${tvgId}
${mimeType}
${tags}
</div>
</td>
</tr>`
}
const options = {
searchColumns: ['title'],
item: (channel) => getChannelTemplate(channel),
};
const values = {{ playlist.channels|values|json_encode|raw }}
const list = new List('chlist', options, values)
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length) list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value)) document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const alert = document.getElementById("toomuchalert"); const alert = document.getElementById("chListLoading")
!!alert && alert.remove() !!alert && alert.remove()
}); });
function savePlaylist() { function savePlaylist() {
const link = document.createElement("a"); const link = document.createElement("a");
const content = document.getElementById("m3u-raw").value const content = document.getElementById("m3u-raw").value
@@ -420,7 +485,7 @@
} }
function updateFilter() { function updateFilter() {
const groupHash = document.getElementById('groupSelector')?.value ?? 'all'; const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked') const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
const tagsSelected = [] const tagsSelected = []
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value)); tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
@@ -428,28 +493,30 @@
switch (activeType) { switch (activeType) {
case 'chfAll': case 'chfAll':
list.filter(item => { list.filter(item => {
const chTags = item.values().chtags.split('|'); const chTags = item.values().tags
const isGroupValid = item.values().group === groupHash || groupHash === 'all'; const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected)); const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0; const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && hasValidTags; return isGroupValid && hasValidTags;
}) })
break break
case 'chfOnline': case 'chfOnline':
list.filter(item => { list.filter(item => {
const isOnline = item.values().online === '1' const isOnline = item.values().isOnline
const chTags = item.values().chtags.split('|'); const chTags = item.values().tags
const isGroupValid = item.values().group === groupHash || groupHash === 'all'; const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected)); const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0; const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOnline && hasValidTags return isGroupValid && isOnline && hasValidTags
}) })
break break
case 'chfOffline': case 'chfOffline':
list.filter(item => { list.filter(item => {
const isOffline = item.values().online === '0' const isOffline = !item.values().isOnline
const chTags = item.values().chtags.split('|'); const chTags = item.values().tags
const isGroupValid = item.values().group === groupHash || groupHash === 'all'; const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected)); const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0; const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOffline && hasValidTags return isGroupValid && isOffline && hasValidTags
@@ -458,4 +525,5 @@
} }
} }
</script> </script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,6 @@
{########################################################################### {###########################################################################
# Copyright (c) 2025, Антон Аксенов # Copyright (c) 2025, Антон Аксенов
# This file is part of iptv.axenov.dev web interface # This file is part of m3u.su project
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE # MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
###########################################################################} ###########################################################################}
@@ -11,32 +11,27 @@
{% block metakeywords %}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{% endblock %} {% block metakeywords %}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{% endblock %}
{% block head %} {% block head %}
<style> <style>
.card {transition: box-shadow .2s, transform .2s} .card {transition: box-shadow .2s, transform .2s}
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px} .card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px} .card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px} .card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
</style> </style>
<script> <script>
function setDefaultLogo(imgtag) { function setDefaultLogo(imgtag) {
imgtag.onerror = null imgtag.onerror = null
imgtag.src = '/no-tvg-logo.png' imgtag.src = '/no-tvg-logo.png'
} }
</script> </script>
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4"> <div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<div class="mb-2"> <div class="mb-2">
<h2 class="mb-0">Список плейлистов ({{ count }})</h2> <h2 class="mb-0">Список плейлистов ({{ count }})</h2>
<div class="text-muted small">Изменён {{ updatedAt }} МСК</div> <div class="text-muted small">Изменён {{ updatedAt }} МСК</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="badge bg-success">online: {{ onlineCount }}</span>
<span class="badge bg-danger">offline: {{ offlineCount }}</span>
<span class="badge bg-secondary">unknown: {{ uncheckedCount }}</span>
</div>
</div> </div>
</div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -54,14 +49,37 @@
<a href="/{{ code }}/details" class="text-decoration-none"> <a href="/{{ code }}/details" class="text-decoration-none">
<div class="card-header d-flex align-items-center gap-2"> <div class="card-header d-flex align-items-center gap-2">
<span class="font-monospace text-{{ statusClass }}">{{ code }}</span> <span class="font-monospace text-{{ statusClass }}">{{ code }}</span>
<span class="badge bg-{{ statusClass }} ms-auto">
{% if playlist.isOnline is same as(true) %}online {% if playlist.isOnline is same as(true) %}
{% elseif playlist.isOnline is same as(false) %}offline <span class="cursor-help badge bg-{{ statusClass }} ms-auto"
{% elseif playlist.isOnline is same as(null) %}unknown title="Возможно, этот плейлист рабочий"
{% endif %} >online</span>
</span> {% elseif playlist.isOnline is same as(false) %}
<span class="cursor-help badge bg-{{ statusClass }} ms-auto"
title="Этот плейлист нерабочий или его не удалось проверить"
>offline</span>
{% elseif playlist.isOnline is same as(null) %}
<span class="cursor-help badge bg-{{ statusClass }} ms-auto"
title="Плейлист ещё не проверялся, придётся подождать"
>unknown</span>
{% endif %}
{% if playlist.isOnline is same as(true) %}
<span class="cursor-help badge border border-success"
title="Процент рабочих каналов"
>{{ playlist.onlinePercent }}%</span>
{% endif %}
{% if "adult" in playlist.tags %} {% if "adult" in playlist.tags %}
<span class="badge bg-warning text-dark" title="Есть каналы для взрослых!">18+</span> <span class="cursor-help badge bg-warning text-dark"
title="Есть каналы для взрослых!"
>18+</span>
{% endif %}
{% if playlist.hasTokens is same as(true) %}
<span class="cursor-help badge bg-info text-dark"
title="В плейлисте есть каналы, которые могут быть нестабильны"
><ion-icon name="paw"></ion-icon></span>
{% endif %} {% endif %}
</div> </div>
</a> </a>
@@ -71,39 +89,39 @@
<h5 class="card-title text-light">{{ playlist.name }}</h5> <h5 class="card-title text-light">{{ playlist.name }}</h5>
</a> </a>
{% if playlist.description is not same as(null) %} {% if playlist.description is not same as(null) %}
<p class="card-text small text-secondary d-none d-md-block">{{ playlist.description }}</p> <p class="card-text small text-secondary d-none d-md-block">{{ playlist.description }}</p>
{% endif %} {% endif %}
<div class="d-flex flex-wrap gap-2 mb-1"> <div class="d-flex flex-wrap gap-2 mb-1">
{% if playlist.isOnline is not same as(null) %} {% if playlist.isOnline is same as(true) %}
<span class="badge border border-secondary">
<ion-icon name="videocam-outline" class="me-1"></ion-icon>&nbsp;{{ playlist.channels|length }}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{% endif %}
{% if playlist.groups|length > 0 %}
<span class="badge border border-secondary"> <span class="badge border border-secondary">
<ion-icon name="folder-open-outline" class="me-1"></ion-icon>&nbsp;{{ playlist.groups|length }}<span class="d-none d-xl-inline-block">&nbsp;групп</span> <ion-icon name="videocam-outline" class="me-1"></ion-icon>&nbsp;{{ playlist.channels|length }}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{% endif %}
{% if playlist.hasTvg %}
<span class="badge border border-secondary">
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{% endif %}
{% if playlist.hasCatchup %}
<span class="badge border border-secondary">
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;Архив</span>
</span> </span>
{% if playlist.groups|length > 0 %}
<span class="badge border border-secondary">
<ion-icon name="folder-open-outline" class="me-1"></ion-icon>&nbsp;{{ playlist.groups|length }}<span class="d-none d-xl-inline-block">&nbsp;групп</span>
</span>
{% endif %}
{% if playlist.hasTvg %}
<span class="badge border border-secondary">
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{% endif %}
{% if playlist.hasCatchup %}
<span class="badge border border-secondary">
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;Архив</span>
</span>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card-footer cursor-pointer" <div class="card-footer cursor-pointer"
onclick="prompt('Скопируй адрес плейлиста. Если не работает, добавь \'.m3u\' в конец.', '{{ mirror_url(playlist.code) }}')" onclick="copyPlaylistUrl('{{ playlist.code }}')"
title="Нажми чтобы скопировать" title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
> >
<div class="d-flex justify-content-between align-items-center small"> <div class="d-flex justify-content-between align-items-center small">
<span class="font-monospace text-truncate"> <span class="font-monospace text-truncate">
{{ mirror_url(playlist.code) }} {{ base_url(playlist.code) }}
</span> </span>
<ion-icon name="copy-outline"></ion-icon> <ion-icon name="copy-outline"></ion-icon>
</div> </div>
@@ -125,7 +143,7 @@
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link bg-dark text-light" href="page/{{ page }}">{{ page }}</a> <a class="page-link bg-dark text-light" href="/page/{{ page }}">{{ page }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@@ -1,6 +1,6 @@
{########################################################################### {###########################################################################
# Copyright (c) 2025, Антон Аксенов # Copyright (c) 2025, Антон Аксенов
# This file is part of iptv.axenov.dev web interface # This file is part of m3u.su project
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE # MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
###########################################################################} ###########################################################################}
@@ -19,7 +19,7 @@
<p class="text-muted small"> <p class="text-muted small">
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению. Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
<br /> <br />
<a href="https://iptv.axenov.dev/docs/support.html#participate">Как это сделать?</a> <a href="/docs/support.html#participate">Как это сделать?</a>
</p> </p>
<a class="btn btn-outline-light" href="/" title="На главную"> <a class="btn btn-outline-light" href="/" title="На главную">
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов <ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов

View File

@@ -1,7 +1,7 @@
{########################################################################### {###########################################################################
# Copyright (c) 2025, Антон Аксенов # Copyright (c) 2025, Антон Аксенов
# This file is part of iptv.axenov.dev web interface # This file is part of m3u.su project
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE # MIT License: {{config('app.repo_url}}/web/src/branch/master/LICENSE
###########################################################################} ###########################################################################}
<!DOCTYPE html> <!DOCTYPE html>
@@ -13,7 +13,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" /> <meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<style>.cursor-pointer{cursor:pointer}</style> <style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script> <script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script> <script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<link href="/css/bootstrap.min.css" rel="stylesheet"> <link href="/css/bootstrap.min.css" rel="stylesheet">
@@ -47,6 +47,11 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" target="_blank" href="https://status.m3u.su">
<ion-icon name="pulse-outline" class="me-1"></ion-icon>&nbsp;Аптайм
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" target="_blank" href="/docs"> <a class="nav-link" target="_blank" href="/docs">
<ion-icon name="document-text-outline" class="me-1"></ion-icon>&nbsp;Документация <ion-icon name="document-text-outline" class="me-1"></ion-icon>&nbsp;Документация
@@ -102,7 +107,7 @@
<a target="_blank" href="/docs" class="text-light text-decoration-none d-flex align-items-center gap-1"> <a target="_blank" href="/docs" class="text-light text-decoration-none d-flex align-items-center gap-1">
<ion-icon name="document-text-outline"></ion-icon>Документация <ion-icon name="document-text-outline"></ion-icon>Документация
</a> </a>
<a target="_blank" href="https://git.axenov.dev/IPTV" class="text-light text-decoration-none d-flex align-items-center gap-1"> <a target="_blank" href="{{ config('app.repo_url') }}" class="text-light text-decoration-none d-flex align-items-center gap-1">
<ion-icon name="code-slash-outline"></ion-icon>Исходники <ion-icon name="code-slash-outline"></ion-icon>Исходники
</a> </a>
<a target="_blank" href="https://axenov.dev" class="text-light text-decoration-none d-flex align-items-center gap-1"> <a target="_blank" href="https://axenov.dev" class="text-light text-decoration-none d-flex align-items-center gap-1">
@@ -120,7 +125,7 @@
</div> </div>
<div> <div>
<a class="small text-secondary d-inline-flex align-items-center gap-1" <a class="small text-secondary d-inline-flex align-items-center gap-1"
href="https://git.axenov.dev/IPTV/web/releases/tag/v{{ version() }}" href="{{ config('app.repo_url') }}/web/releases/tag/v{{ version() }}"
target="_blank" target="_blank"
> >
<ion-icon name="pricetag-outline"></ion-icon>v{{ version() }} <ion-icon name="pricetag-outline"></ion-icon>v{{ version() }}
@@ -129,9 +134,58 @@
</div> </div>
</footer> </footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
<div class="d-flex">
<div class="toast-body" id="clipboardToastBody"></div>
</div>
</div>
</div>
<script src="/js/bootstrap.bundle.min.js"></script> <script src="/js/bootstrap.bundle.min.js"></script>
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
<script>
function showToast(message) {
const toastEl = document.getElementById('clipboardToast');
const toastBodyEl = document.getElementById('clipboardToastBody');
toastBodyEl.innerHTML = message;
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
toast.show();
}
function copyPlaylistUrl(code) {
const url = '{{ base_url() }}/' + code;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(url)
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
.catch(err => console.error('Failed to copy:', err));
} else {
try {
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed"; // Avoid scrolling to bottom
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
} else {
showToast('Ошибка при копировании ссылки', true);
}
} catch (err) {
console.error('Fallback copy failed:', err);
showToast('Ошибка при копировании ссылки', true);
}
}
}
</script>
{% include("custom.twig") ignore missing %} {% include("custom.twig") ignore missing %}
</body> </body>
</html> </html>