Compare commits
5 Commits
23f388172c
...
refactor3
| Author | SHA1 | Date | |
|---|---|---|---|
|
6c31ffa120
|
|||
|
5c1b19c08a
|
|||
|
ffaed2b657
|
|||
|
006533169f
|
|||
|
d1efb0dcd3
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,8 +7,10 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
playlists.ini
|
|
||||||
channels.json
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
config/playlists.ini
|
||||||
|
channels.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
|
||||||
!/**/.gitkeep
|
!/**/.gitkeep
|
||||||
|
|||||||
732
.php-cs-fixer.php
Normal file
732
.php-cs-fixer.php
Normal 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); // рискованные правила разрешаем, но в общий список добавляем аккуратно
|
||||||
23
README.md
23
README.md
@@ -6,11 +6,10 @@
|
|||||||
|
|
||||||
> **Веб-сайт:** [m3u.su](https://m3u.su)
|
> **Веб-сайт:** [m3u.su](https://m3u.su)
|
||||||
> **Документация:** [m3u.su/docs](https://m3u.su/docs)
|
> **Документация:** [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)
|
||||||
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
|
> Бот: [@iptv_aggregator_bot](https://t.me/iptv_aggregator_bot)
|
||||||
> Дополнительные сведения: [git.axenov.dev/IPTV/.profile](https://git.axenov.dev/IPTV/.profile)
|
|
||||||
|
|
||||||
## О веб-сервисе
|
## О веб-сервисе
|
||||||
|
|
||||||
@@ -27,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` или её отсутствия в нём.
|
||||||
Но некорректные значения некоторых переменных могут привести к фатальным ошибкам.
|
Но некорректные значения некоторых переменных могут привести к фатальным ошибкам.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Bot;
|
use App\Core\Bot;
|
||||||
use App\Core\Kernel;
|
use App\Core\Kernel;
|
||||||
use App\Core\StatisticsService;
|
use App\Core\StatisticsService;
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use chillerlan\QRCode\QRCode;
|
use chillerlan\QRCode\QRCode;
|
||||||
use chillerlan\QRCode\QROptions;
|
use chillerlan\QRCode\QROptions;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -38,7 +38,7 @@ class ApiController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'] ?? null;
|
$code = $request->getAttributes()['code'] ?? null;
|
||||||
empty($code) && throw new PlaylistNotFoundException('');
|
empty($code) && throw new PlaylistNotFoundException('');
|
||||||
|
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($code);
|
||||||
if ($playlist['isOnline'] === true) {
|
if ($playlist['isOnline'] === true) {
|
||||||
unset($playlist['content']);
|
unset($playlist['content']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
@@ -36,7 +36,7 @@ class WebController extends BasicController
|
|||||||
*/
|
*/
|
||||||
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
{
|
{
|
||||||
$ini = ini()->load();
|
$ini = ini();
|
||||||
$keys = [];
|
$keys = [];
|
||||||
$count = count($ini);
|
$count = count($ini);
|
||||||
$pageSize = config('app.page_size');
|
$pageSize = config('app.page_size');
|
||||||
@@ -45,11 +45,11 @@ class WebController extends BasicController
|
|||||||
$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);
|
||||||
$ini = array_slice($ini, $offset, $pageSize, true);
|
$ini = array_slice($ini->get, $offset, $pageSize, true);
|
||||||
$keys = array_keys($ini);
|
$keys = array_keys($ini);
|
||||||
}
|
}
|
||||||
|
|
||||||
$playlists = ini()->getPlaylists($keys);
|
$playlists = ini()->playlists($keys);
|
||||||
|
|
||||||
return $this->view($request, $response, 'list.twig', [
|
return $this->view($request, $response, 'list.twig', [
|
||||||
'updatedAt' => ini()->updatedAt(),
|
'updatedAt' => ini()->updatedAt(),
|
||||||
@@ -75,7 +75,7 @@ class WebController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($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);
|
||||||
@@ -98,7 +98,7 @@ class WebController extends BasicController
|
|||||||
$code = $request->getAttributes()['code'];
|
$code = $request->getAttributes()['code'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$playlist = ini()->getPlaylist($code);
|
$playlist = ini()->playlist($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);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
use App\Errors\InvalidTelegramSecretException;
|
use App\Exceptions\InvalidTelegramSecretException;
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Exception;
|
use Exception;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
@@ -168,7 +168,7 @@ class Bot
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pls = ini()->getPlaylist($code);
|
$pls = ini()->playlist($code);
|
||||||
} catch (PlaylistNotFoundException) {
|
} catch (PlaylistNotFoundException) {
|
||||||
return $this->reply("Плейлист `$code` не найден");
|
return $this->reply("Плейлист `$code` не найден");
|
||||||
}
|
}
|
||||||
@@ -216,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") . '`';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -9,70 +10,71 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
use App\Errors\PlaylistNotFoundException;
|
use App\Exceptions\FileReadException;
|
||||||
use Exception;
|
use App\Exceptions\IniParsingException;
|
||||||
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
|
use ArrayAccess;
|
||||||
|
use Override;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Класс для работы со списком плейлистов
|
* Класс для работы со списком плейлистов
|
||||||
|
*
|
||||||
|
* @phpstan-type TIniFile array{}|TPlaylistDefinition[]
|
||||||
|
* @phpstan-type TPlaylistDefinition array{name?: string, desc?: string, url: string, src?: string}
|
||||||
|
* @template TKey as non-falsy-string
|
||||||
|
* @template TValue as TPlaylistDefinition
|
||||||
|
* @implements ArrayAccess<TKey, TValue>
|
||||||
*/
|
*/
|
||||||
class IniFile
|
class IniFile implements ArrayAccess
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var array[] Коллекция подгруженных плейлистов
|
* @var array{}|array<TKey, TValue> Коллекция подгруженных плейлистов
|
||||||
*/
|
*/
|
||||||
protected array $playlists;
|
protected array $playlists;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Дата последнего обновления списка
|
* @var positive-int Дата последнего обновления списка
|
||||||
*/
|
*/
|
||||||
protected string $updatedAt;
|
protected int $updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Считывает ini-файл и инициализирует плейлисты
|
* Загружает ini-файл и инициализирует плейлисты
|
||||||
*
|
*
|
||||||
* @return array
|
* @param string $filepath
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
*/
|
*/
|
||||||
public function load(): array
|
public function __construct(
|
||||||
{
|
protected string $filepath,
|
||||||
$filepath = config_path('playlists.ini');
|
) {
|
||||||
$this->playlists = parse_ini_file($filepath, true);
|
try {
|
||||||
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath));
|
$content = file_get_contents($this->filepath);
|
||||||
|
} catch (Throwable) {
|
||||||
return $this->playlists;
|
$content = false;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает плейлисты
|
|
||||||
*
|
|
||||||
* @return array[]
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function getPlaylists(array $plsCodes = []): array
|
|
||||||
{
|
|
||||||
$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;
|
$content === false && throw new FileReadException($this->filepath);
|
||||||
|
|
||||||
|
$parsed = parse_ini_string($content, true);
|
||||||
|
$parsed === false && throw new IniParsingException($this->filepath);
|
||||||
|
$this->playlists = $parsed;
|
||||||
|
|
||||||
|
/** @var positive-int $timestamp */
|
||||||
|
$timestamp = is_readable($this->filepath) ? filemtime($this->filepath) : time();
|
||||||
|
$this->updatedAt = $timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает плейлист по его коду
|
* Возвращает определение плейлиста по его коду
|
||||||
*
|
*
|
||||||
* @param string $code Код плейлиста
|
* @param TKey $code
|
||||||
* @return array|null
|
* @return TValue
|
||||||
* @throws PlaylistNotFoundException
|
* @throws PlaylistNotFoundException
|
||||||
* @throws Exception
|
|
||||||
*/
|
*/
|
||||||
public function getPlaylist(string $code): ?array
|
public function playlist(string $code): array
|
||||||
{
|
{
|
||||||
empty($this->playlists) && $this->load();
|
return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
|
||||||
$data = redis()->get($code);
|
|
||||||
return $this->initPlaylist($code, $data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,107 +84,50 @@ class IniFile
|
|||||||
*/
|
*/
|
||||||
public function updatedAt(): string
|
public function updatedAt(): string
|
||||||
{
|
{
|
||||||
return $this->updatedAt;
|
return date('d.m.Y h:i', $this->updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подготавливает данные о плейлисте в расширенном формате
|
* @inheritDoc
|
||||||
*
|
* @param non-falsy-string $offset
|
||||||
* @param string $code
|
|
||||||
* @param array|false $data
|
|
||||||
* @return array
|
|
||||||
* @throws PlaylistNotFoundException
|
|
||||||
*/
|
|
||||||
protected function initPlaylist(string $code, array|false $data): array
|
|
||||||
{
|
|
||||||
if ($data === false) {
|
|
||||||
$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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// приколы 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
|
* @return bool
|
||||||
*/
|
*/
|
||||||
protected function hasTokens(array $data): bool
|
#[Override]
|
||||||
|
public function offsetExists(mixed $offset): bool
|
||||||
{
|
{
|
||||||
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
|
return isset($this->playlists[$offset]);
|
||||||
if (empty($string)) {
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$badAttributes = [
|
/**
|
||||||
// токены и ключи
|
* @inheritDoc
|
||||||
'[?&]token=',
|
* @param TKey $offset
|
||||||
'[?&]drmreq=',
|
* @return TPlaylistDefinition
|
||||||
// логины
|
* @throws PlaylistNotFoundException
|
||||||
'[?&]u=',
|
*/
|
||||||
'[?&]user=',
|
#[Override]
|
||||||
'[?&]username=',
|
public function offsetGet(mixed $offset): array
|
||||||
// пароли
|
{
|
||||||
'[?&]p=',
|
return $this->playlist($offset);
|
||||||
'[?&]pwd=',
|
}
|
||||||
'[?&]password=',
|
|
||||||
// неизвестные
|
|
||||||
// 'free=true',
|
|
||||||
// 'uid=',
|
|
||||||
// 'c_uniq_tag=',
|
|
||||||
// 'rlkey=',
|
|
||||||
// '?s=',
|
|
||||||
// '&s=',
|
|
||||||
// '?q=',
|
|
||||||
// '&q=',
|
|
||||||
];
|
|
||||||
|
|
||||||
return array_any(
|
/**
|
||||||
$badAttributes,
|
* @inheritDoc
|
||||||
static fn (string $badAttribute) => preg_match_all("/$badAttribute/", $string) >= 1,
|
* @param TKey $offset
|
||||||
);
|
* @param TValue $value
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function offsetSet(mixed $offset, mixed $value): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
* @param non-falsy-string $offset
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function offsetUnset(mixed $offset): void
|
||||||
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,6 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
public function ini(): IniFile
|
public function ini(): IniFile
|
||||||
{
|
{
|
||||||
return $this->iniFile ??= new IniFile();
|
return $this->iniFile ??= new IniFile(config_path('playlists.ini'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
216
app/Core/Playlist.php
Normal file
216
app/Core/Playlist.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?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 App\Exceptions\PlaylistNotFoundException;
|
||||||
|
use App\Exceptions\PlaylistWithoutUrlException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-import-type TPlaylistDefinition from IniFile
|
||||||
|
*/
|
||||||
|
class Playlist
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var non-falsy-string
|
||||||
|
*/
|
||||||
|
public readonly string $code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var non-falsy-string|null
|
||||||
|
*/
|
||||||
|
public readonly ?string $desc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var non-falsy-string|null
|
||||||
|
*/
|
||||||
|
public readonly ?string $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var non-falsy-string
|
||||||
|
*/
|
||||||
|
public readonly string $url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var non-falsy-string|null
|
||||||
|
*/
|
||||||
|
public readonly ?string $src;
|
||||||
|
|
||||||
|
protected string $text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-falsy-string $code
|
||||||
|
* @param TPlaylistDefinition $params
|
||||||
|
*/
|
||||||
|
public function __construct(string $code, array $params)
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
$this->name = isset($params['name']) ? trim($params['name']) : null;
|
||||||
|
$this->desc = isset($params['desc']) ? trim($params['desc']) : null;
|
||||||
|
$this->url = isset($params['url']) ? trim($params['url']) : throw new PlaylistWithoutUrlException($code);
|
||||||
|
$this->src = isset($params['src']) ? trim($params['src']) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCheckResult(\Redis $redis)
|
||||||
|
{
|
||||||
|
$this->text = $redis->get($this->code);
|
||||||
|
|
||||||
|
|
||||||
|
$stop = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private array $definition;
|
||||||
|
|
||||||
|
private array $cached;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает плейлисты
|
||||||
|
*
|
||||||
|
* @return array[]
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
// public function getCachedPlaylists(array $plsCodes = []): array
|
||||||
|
// {
|
||||||
|
// $playlists = [];
|
||||||
|
// 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 getCachedPlaylist(string $code): ?array
|
||||||
|
// {
|
||||||
|
// $data = redis()->get($code);
|
||||||
|
//
|
||||||
|
// return $this->initPlaylist($code, $data);
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подготавливает данные о плейлисте в расширенном формате
|
||||||
|
*
|
||||||
|
* @param string $code
|
||||||
|
* @param array|false $data
|
||||||
|
* @return array
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
*/
|
||||||
|
protected function initPlaylist(string $code, array|false $data): array
|
||||||
|
{
|
||||||
|
if ($data === false) {
|
||||||
|
$raw = $this->playlists[$code]
|
||||||
|
?? throw new PlaylistNotFoundException($code);
|
||||||
|
$data = [
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $raw['name'] ?? "Плейлист #{$code}",
|
||||||
|
'description' => $raw['desc'] ?? null,
|
||||||
|
'url' => $raw['url'],
|
||||||
|
'source' => $raw['src'] ?? null,
|
||||||
|
'content' => null,
|
||||||
|
'isOnline' => null,
|
||||||
|
'attributes' => [],
|
||||||
|
'groups' => [],
|
||||||
|
'channels' => [],
|
||||||
|
'checkedAt' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// приколы 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,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 +34,17 @@ class TwigExtention extends AbstractExtension
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
* @noinspection PhpUnused
|
||||||
|
*/
|
||||||
|
public function getFilters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFilter('values', [$this, 'arrayValues']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает значение из конфига
|
* Возвращает значение из конфига
|
||||||
*
|
*
|
||||||
@@ -88,4 +100,9 @@ class TwigExtention extends AbstractExtension
|
|||||||
{
|
{
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Psr\Http\Message\{
|
use Psr\Http\Message\{
|
||||||
ResponseInterface,
|
ResponseInterface,
|
||||||
@@ -19,7 +19,7 @@ use Throwable;
|
|||||||
/**
|
/**
|
||||||
* Обработчик ошибок
|
* Обработчик ошибок
|
||||||
*/
|
*/
|
||||||
class ErrorHandler extends SlimErrorHandler
|
class ExceptionHandler extends SlimErrorHandler
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
|
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым
|
||||||
20
app/Exceptions/FileNotFoundException.php
Normal file
20
app/Exceptions/FileNotFoundException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class FileNotFoundException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $filepath)
|
||||||
|
{
|
||||||
|
parent::__construct('Файл не найден: ' . $filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Exceptions/FileReadException.php
Normal file
20
app/Exceptions/FileReadException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class FileReadException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $filepath)
|
||||||
|
{
|
||||||
|
parent::__construct('Ошибка чтения файла: ' . $filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Exceptions/IniParsingException.php
Normal file
20
app/Exceptions/IniParsingException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class IniParsingException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $filepath)
|
||||||
|
{
|
||||||
|
parent::__construct('Ошибка разбора файла: ' . $filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025, Антон Аксенов
|
* Copyright (c) 2025, Антон Аксенов
|
||||||
* This file is part of m3u.su project
|
* This file is part of m3u.su project
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Errors;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
@@ -15,6 +16,6 @@ class PlaylistNotFoundException extends Exception
|
|||||||
{
|
{
|
||||||
public function __construct(string $code)
|
public function __construct(string $code)
|
||||||
{
|
{
|
||||||
parent::__construct("Плейлист '$code' не найден");
|
parent::__construct("Плейлист '{$code}' не найден");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
20
app/Exceptions/PlaylistWithoutUrlException.php
Normal file
20
app/Exceptions/PlaylistWithoutUrlException.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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\Exceptions;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class PlaylistWithoutUrlException extends InvalidArgumentException
|
||||||
|
{
|
||||||
|
public function __construct(string $code)
|
||||||
|
{
|
||||||
|
parent::__construct("Плейлист '{$code}' имеет неверный url");
|
||||||
|
}
|
||||||
|
}
|
||||||
1677
app/helpers.php
1677
app/helpers.php
File diff suppressed because it is too large
Load Diff
@@ -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/"
|
||||||
@@ -34,6 +44,11 @@
|
|||||||
"app/helpers.php"
|
"app/helpers.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clear-views": "rm -rf cache/views",
|
"clear-views": "rm -rf cache/views",
|
||||||
"post-install-cmd": [
|
"post-install-cmd": [
|
||||||
|
|||||||
4492
composer.lock
generated
4492
composer.lock
generated
File diff suppressed because it is too large
Load Diff
425
linter
Executable file
425
linter
Executable file
@@ -0,0 +1,425 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# set -x
|
||||||
|
# set -o pipefail
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# Служебные исходные переменные
|
||||||
|
########################################################
|
||||||
|
|
||||||
|
# имя контейнера
|
||||||
|
CONTAINER="m3u-su-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 == 1 ]] && DEBUG_MODE=1
|
||||||
|
[[ $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 ]] && FBLACK="$(tput setaf 0)" || FBLACK=''
|
||||||
|
[[ "$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=''
|
||||||
|
[[ "$LINTER_COLORS" == 1 ]] && FBLYELLOW="$(tput setab 11)" || FBLYELLOW=''
|
||||||
|
|
||||||
|
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 хук НЕ установлен"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Запускает проверку код-стайла по всему проекту или только изменённым файлам
|
||||||
|
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 ;;
|
||||||
|
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
435
phpcs.xml
Normal 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>-->
|
||||||
|
<!-- <!– первый операнд в мультистроковых ифах должен быть на новой строке –>-->
|
||||||
|
<!-- <exclude name="PEAR.ControlStructures.MultiLineCondition.SpacingAfterOpenBrace" />-->
|
||||||
|
<!-- <!– не каждый операнд в мультистроковых ифах должен начинаться с булева оператора –>-->
|
||||||
|
<!-- <exclude name="PEAR.ControlStructures.MultiLineCondition.StartWithBoolean" />-->
|
||||||
|
<!--</rule>-->
|
||||||
|
|
||||||
|
<!--<rule ref="PEAR.Functions.FunctionCallSignature">-->
|
||||||
|
<!-- <!–конфликтует с PEAR.WhiteSpace.ObjectOperatorIndent–>-->
|
||||||
|
<!-- <properties>-->
|
||||||
|
<!-- <property name="allowMultipleArguments" value="true" />-->
|
||||||
|
<!-- <property name="indent" value="4" />-->
|
||||||
|
<!-- <property name="requiredSpacesAfterOpen" value="0" />-->
|
||||||
|
<!-- <property name="requiredSpacesBeforeClose" value="0" />-->
|
||||||
|
<!-- </properties>-->
|
||||||
|
<!-- <!– ругается на открывающие скобки в одной строке –>-->
|
||||||
|
<!-- <exclude name="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket" />-->
|
||||||
|
<!-- <!– ругается на закрывающие скобки в одной строке –>-->
|
||||||
|
<!-- <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">-->
|
||||||
|
<!-- <!– конфликтует с Generic.WhiteSpace.ScopeIndent –>-->
|
||||||
|
<!-- <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">-->
|
||||||
|
<!-- <!– не умеет считать выбросы исключений из глубины –>-->
|
||||||
|
<!-- <exclude name="Squiz.Commenting.FunctionCommentThrowTag.WrongNumber" />-->
|
||||||
|
<!--</rule>-->
|
||||||
|
|
||||||
|
<!--<rule ref="Squiz.Commenting.VariableComment">-->
|
||||||
|
<!-- <!– требует длинное название типа (integer, boolean, ...) вместо нормального (int, bool, ..) –>-->
|
||||||
|
<!-- <exclude name="Squiz.Commenting.VariableComment.IncorrectVarType" />-->
|
||||||
|
<!-- <!– не понимает наличие аннотации между докблоком и декларацией –>-->
|
||||||
|
<!-- <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>-->
|
||||||
|
<!-- <!– требует выход из кейса на 1 уровне с кейсом –>-->
|
||||||
|
<!-- <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">-->
|
||||||
|
<!-- <!– требует _ в начале приватных методов –>-->
|
||||||
|
<!-- <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">-->
|
||||||
|
<!-- <!– первый операнд в мультистроковых ифах должен быть на новой строке –>-->
|
||||||
|
<!-- <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
48
phpstan.neon
Normal 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
43
phpunit.xml
Normal 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>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 235.6 292.2" style="enable-background:new 0 0 235.6 292.2;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<g id="b_1_">
|
|
||||||
<path class="st0" d="M44.3,164.5L76.9,51.6H127l-10.1,35c-0.1,0.2-0.2,0.4-0.3,0.6L90,179.6h24.8c-10.4,25.9-18.5,46.2-24.3,60.9
|
|
||||||
c-45.8-0.5-58.6-33.3-47.4-72.1 M90.7,240.6l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5
|
|
||||||
c-11.3,39.1-57.1,72.1-101.7,72.1C91.3,240.6,91,240.6,90.7,240.6z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 735 B |
340
tests/BaseTestCase.php
Normal file
340
tests/BaseTestCase.php
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<?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 Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
abstract class BaseTestCase extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Тестирует наличие методов в классе
|
||||||
|
*
|
||||||
|
* @param array $methods
|
||||||
|
* @param object|string $class
|
||||||
|
*/
|
||||||
|
public function assertHasMethods(array $methods, object|string $class): void
|
||||||
|
{
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$this->assertTrue(
|
||||||
|
method_exists(is_object($class) ? $class::class : $class, $method),
|
||||||
|
"Method {$class}::{$method}() does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makePlaylist(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeIni(?string $contents = null): string
|
||||||
|
{
|
||||||
|
$contents ??= <<<'EOD'
|
||||||
|
[foo]
|
||||||
|
name=foo name
|
||||||
|
desc=foo description
|
||||||
|
url=http://example.com/foo.m3u
|
||||||
|
src=http://example.com/
|
||||||
|
[bar]
|
||||||
|
name=bar name
|
||||||
|
desc=bar description
|
||||||
|
url=http://example.com/bar.m3u
|
||||||
|
src=http://example.com/
|
||||||
|
EOD;
|
||||||
|
|
||||||
|
return data_stream($contents, 'text/ini');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Методы для заглушки объектов и методов других классов
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт и возвращает объект HTTP-запроса для тестирования методов контроллеров
|
||||||
|
*
|
||||||
|
* @param array $query The GET parameters
|
||||||
|
* @param array $request The POST parameters
|
||||||
|
* @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
|
||||||
|
* @param array $cookies The COOKIE parameters
|
||||||
|
* @param array $files The FILES parameters
|
||||||
|
* @param array $server The SERVER parameters
|
||||||
|
* @param resource|string|null $content The raw body data
|
||||||
|
*
|
||||||
|
* @return HttpRequest
|
||||||
|
*
|
||||||
|
* @see Request::__construct
|
||||||
|
*/
|
||||||
|
protected function makeHttpRequest(
|
||||||
|
array $query = [],
|
||||||
|
array $request = [],
|
||||||
|
array $attributes = [],
|
||||||
|
array $cookies = [],
|
||||||
|
array $files = [],
|
||||||
|
array $server = [],
|
||||||
|
mixed $content = null
|
||||||
|
): HttpRequest {
|
||||||
|
return new HttpRequest(func_get_args());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает объект ответа HTTP-клиента Laravel
|
||||||
|
*
|
||||||
|
* @param int $status
|
||||||
|
* @param array $headers
|
||||||
|
* @param $body
|
||||||
|
* @param string $version
|
||||||
|
* @param string|null $reason
|
||||||
|
*
|
||||||
|
* @return HttpClientResponse
|
||||||
|
*/
|
||||||
|
protected function makeHttpResponse(
|
||||||
|
int $status = 200,
|
||||||
|
array $headers = [],
|
||||||
|
$body = null,
|
||||||
|
string $version = '1.1',
|
||||||
|
?string $reason = null
|
||||||
|
): HttpClientResponse {
|
||||||
|
return new HttpClientResponse(new GuzzleResponse(...func_get_args()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывает любой метод указанного объекта с нужными аргументами и обходом его видимости
|
||||||
|
*
|
||||||
|
* @param class-string|object $objectOrClass
|
||||||
|
* @param string $method
|
||||||
|
* @param mixed ...$args
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*
|
||||||
|
* @throws ReflectionException
|
||||||
|
*
|
||||||
|
* @see https://stackoverflow.com/questions/249664
|
||||||
|
* @see \Psy\Sudo::callMethod()
|
||||||
|
* @see \Psy\Sudo::callStatic()
|
||||||
|
*/
|
||||||
|
protected function callMethod(object|string $objectOrClass, string $method, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
$reflObject = is_string($objectOrClass)
|
||||||
|
? new ReflectionClass($objectOrClass)
|
||||||
|
: new ReflectionObject($objectOrClass);
|
||||||
|
$reflMethod = $reflObject->getMethod($method);
|
||||||
|
|
||||||
|
return $reflMethod->invokeArgs(is_string($objectOrClass) ? null : $objectOrClass, $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Методы-хелперы для подготовки и отладки тестов
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует многоуровневый массив в html-файл с таблицей для визуальной
|
||||||
|
* отладки и сохраняет в `"storage/app/$filename"`.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* 0. предполагается во время отладки теста
|
||||||
|
* 1. вызвать метод, который возвращает массив, приводимый к массиву или читаемый как массив объект
|
||||||
|
* 2. вызвать `$this->toTable($array, 'test')`
|
||||||
|
* 3. открыть в браузере файл `storage/app/test.html`
|
||||||
|
*
|
||||||
|
* @param array|ArrayAccess $data
|
||||||
|
* @param string $filename
|
||||||
|
*/
|
||||||
|
protected static function toTable(array|ArrayAccess $data, string $filename): void
|
||||||
|
{
|
||||||
|
$headers = $result = $html = [];
|
||||||
|
foreach ($data as $row) {
|
||||||
|
$result[] = $row = Arr::dot($row);
|
||||||
|
empty($headers) && $headers = array_keys($row);
|
||||||
|
}
|
||||||
|
$html[] = '<html lang="ru"><style>body{margin:0}table{font-family:monospace;border-collapse:collapse}'
|
||||||
|
. 'thead{background:darkorange;position:sticky;top:-1}th,td{white-space:nowrap;'
|
||||||
|
. 'border:1px solid black;padding:0 2px}tr:active{font-weight:bold}'
|
||||||
|
. 'tr:hover{background:lightgrey}</style><body><table><thead>';
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
$html[] = "<th>{$header}</th>";
|
||||||
|
}
|
||||||
|
$html[] = '</thead><tbody>';
|
||||||
|
|
||||||
|
foreach ($result as $row) {
|
||||||
|
$html[] = '<tr>';
|
||||||
|
foreach ($row as $value) {
|
||||||
|
$value instanceof BackedEnum && $value = $value->value;
|
||||||
|
$value = str_replace("'", '', var_export($value, true)); // строки без кавычек
|
||||||
|
$html[] = "<td>{$value}</td>";
|
||||||
|
}
|
||||||
|
$html[] = '</tr>';
|
||||||
|
}
|
||||||
|
$html[] = '</tbody></table></body></html>';
|
||||||
|
Storage::put("{$filename}.html", implode('', $html));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Методы проверки значений
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет идентичность двух классов
|
||||||
|
*
|
||||||
|
* @param object|string $class1
|
||||||
|
* @param object|string $class2
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function checkIsSameClass(object|string $class1, object|string $class2): bool
|
||||||
|
{
|
||||||
|
return (is_object($class1) ? $class1::class : $class1) === (is_object($class2) ? $class2::class : $class2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет наследование других классов указанным
|
||||||
|
*
|
||||||
|
* @param string[] $parents Массив имён потенциальных классов-родителей
|
||||||
|
* @param object|string $class Объект или имя класса для проверки
|
||||||
|
*
|
||||||
|
* @see https://www.php.net/manual/en/function.class-parents.php
|
||||||
|
*/
|
||||||
|
protected function checkExtendsClasses(array $parents, object|string $class): bool
|
||||||
|
{
|
||||||
|
return !empty(array_intersect($parents, is_object($class) ? class_parents($class) : [$class]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет реализацию интерфейсов указанным классом
|
||||||
|
*
|
||||||
|
* @param string[] $interfaces Массив имён интерфейсов
|
||||||
|
* @param object|string $class Объект или имя класса для проверки
|
||||||
|
*
|
||||||
|
* @see https://www.php.net/manual/en/function.class-parents.php
|
||||||
|
*/
|
||||||
|
protected function checkImplementsInterfaces(array $interfaces, object|string $class): bool
|
||||||
|
{
|
||||||
|
return !empty(array_intersect($interfaces, is_object($class) ? class_implements($class) : [$class]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Методы проверки утверждений в тестах
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в массиве имеются все указанные ключи
|
||||||
|
*
|
||||||
|
* @param array $keys Ключи для проверки в массиве
|
||||||
|
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertArrayHasKeys(array $keys, iterable $array): void
|
||||||
|
{
|
||||||
|
$array = iterator_to_array($array);
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$this->assertArrayHasKey($key, $array);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в объекте имеются все указанные свойства
|
||||||
|
*
|
||||||
|
* @param array $props Свойства для проверки в объекте
|
||||||
|
* @param object $object Проверяемый объект
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertObjectHasProperties(array $props, object $object): void
|
||||||
|
{
|
||||||
|
foreach ($props as $prop) {
|
||||||
|
$this->assertObjectHasProperty($prop, $object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в массиве отсутствуют все указанные ключи
|
||||||
|
*
|
||||||
|
* @param array $keys Ключи для проверки в массиве
|
||||||
|
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertArrayNotHasKeys(array $keys, iterable $array): void
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$this->assertArrayNotHasKey($key, $array);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в объекте отсутствуют все указанные свойства
|
||||||
|
*
|
||||||
|
* @param array $props Свойства для проверки в объекте
|
||||||
|
* @param object $object Проверяемый объект
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertObjectNotHasProperties(array $props, object $object): void
|
||||||
|
{
|
||||||
|
foreach ($props as $prop) {
|
||||||
|
$this->assertObjectNotHasProperty($prop, $object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в массиве элементы только указанного типа
|
||||||
|
*
|
||||||
|
* @param string $type Название типа, один из возможных результатов функции gettype()
|
||||||
|
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertArrayValuesTypeOf(string $type, iterable $array): void
|
||||||
|
{
|
||||||
|
foreach ($array as $key => $value) {
|
||||||
|
$this->assertEquals($type, gettype($value), "Failed asserting that element [{$key}] is type of {$type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утверждает, что в массив содержит только объекты (опционально -- указанного класса)
|
||||||
|
*
|
||||||
|
* Работает гибче {@link self::assertContainsOnlyInstancesOf()}
|
||||||
|
* засчёт предварительной подготовки проверяемых данных и возможности
|
||||||
|
* нестрогой проверки имени класса.
|
||||||
|
*
|
||||||
|
* @param mixed $array Массив для проверки
|
||||||
|
* @param object|string|null $class Имя класса (если не указано, проверяется только тип)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function assertIsArrayOfObjects(mixed $array, object|string|null $class = null): void
|
||||||
|
{
|
||||||
|
is_object($class) && $class = $class::class;
|
||||||
|
|
||||||
|
if (is_string($array) && json_validate($array)) {
|
||||||
|
$array = json_decode($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty($array);
|
||||||
|
|
||||||
|
if (empty($class)) {
|
||||||
|
$filtered = array_filter($array, static fn ($elem) => is_object($elem));
|
||||||
|
$this->assertSame($array, $filtered, 'Failed asserting that array containts only objects');
|
||||||
|
} else {
|
||||||
|
$this->assertContainsOnlyInstancesOf($class, $array);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tests/Controllers/ApiControllerTest.php
Normal file
37
tests/Controllers/ApiControllerTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?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 Tests\Controllers;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ApiControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
public function testMakeQrCode()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetOne()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHealth()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStats()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVersion()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
113
tests/Core/IniFileTest.php
Normal file
113
tests/Core/IniFileTest.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?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 Tests\Core;
|
||||||
|
|
||||||
|
use App\Core\IniFile;
|
||||||
|
use App\Exceptions\FileReadException;
|
||||||
|
use App\Exceptions\IniParsingException;
|
||||||
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
|
use Tests\BaseTestCase;
|
||||||
|
use Tests\FixtureHandler;
|
||||||
|
|
||||||
|
class IniFileTest extends BaseTestCase
|
||||||
|
{
|
||||||
|
use FixtureHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет успешное создание объекта, чтение и парсинг файла
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
*/
|
||||||
|
public function testMain(): void
|
||||||
|
{
|
||||||
|
$ini = $this->makeIni();
|
||||||
|
$ini = new IniFile($ini);
|
||||||
|
|
||||||
|
$this->assertNotNull($ini->updatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
*/
|
||||||
|
public function testFileReadException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(FileReadException::class);
|
||||||
|
$this->expectExceptionMessage('Ошибка чтения файла');
|
||||||
|
$ini = '';
|
||||||
|
|
||||||
|
new IniFile($ini);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет исключение при попытке парсинга битого ini-файла
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
*/
|
||||||
|
public function testIniParsingException(): void
|
||||||
|
{
|
||||||
|
$this->expectException(IniParsingException::class);
|
||||||
|
$this->expectExceptionMessage('Ошибка разбора файла');
|
||||||
|
$ini = $this->makeIni('z]');
|
||||||
|
|
||||||
|
new IniFile($ini);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет успешное получение определение плейлиста из ini-файла
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
*/
|
||||||
|
public function testGetPlaylist(): void
|
||||||
|
{
|
||||||
|
$ini = $this->makeIni();
|
||||||
|
$ini = new IniFile($ini);
|
||||||
|
$isset = isset($ini['foo']);
|
||||||
|
$foo = $ini->playlist('foo');
|
||||||
|
$foo2 = $ini['foo'];
|
||||||
|
|
||||||
|
$this->assertTrue($isset);
|
||||||
|
$this->assertIsArray($foo);
|
||||||
|
$this->assertSame('foo name', $foo['name']);
|
||||||
|
$this->assertSame('foo description', $foo['desc']);
|
||||||
|
$this->assertSame('http://example.com/foo.m3u', $foo['url']);
|
||||||
|
$this->assertSame('http://example.com/', $foo['src']);
|
||||||
|
$this->assertSame($foo, $foo2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет исключение при попытке парсинга битого ini-файла
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
* @throws IniParsingException
|
||||||
|
*/
|
||||||
|
public function testPlaylistNotFoundException(): void
|
||||||
|
{
|
||||||
|
$code = 'test';
|
||||||
|
$this->expectException(PlaylistNotFoundException::class);
|
||||||
|
$this->expectExceptionMessage("Плейлист '{$code}' не найден");
|
||||||
|
$ini = $this->makeIni();
|
||||||
|
|
||||||
|
(new IniFile($ini))->playlist($code);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
tests/Core/PlaylistTest.php
Normal file
111
tests/Core/PlaylistTest.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?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 Tests\Core;
|
||||||
|
|
||||||
|
use App\Core\IniFile;
|
||||||
|
use App\Core\Playlist;
|
||||||
|
use App\Exceptions\FileReadException;
|
||||||
|
use App\Exceptions\IniParsingException;
|
||||||
|
use App\Exceptions\PlaylistNotFoundException;
|
||||||
|
use App\Exceptions\PlaylistWithoutUrlException;
|
||||||
|
use Redis;
|
||||||
|
use Tests\BaseTestCase;
|
||||||
|
use Tests\FixtureHandler;
|
||||||
|
|
||||||
|
class PlaylistTest extends BaseTestCase
|
||||||
|
{
|
||||||
|
use FixtureHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет успешное создание объекта
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
*/
|
||||||
|
public function testMain(): void
|
||||||
|
{
|
||||||
|
$code = 'foo';
|
||||||
|
$ini = new IniFile($this->makeIni());
|
||||||
|
$definition = $ini->playlist($code);
|
||||||
|
|
||||||
|
$pls = new Playlist($code, $definition);
|
||||||
|
|
||||||
|
$this->assertSame($code, $pls->code);
|
||||||
|
$this->assertSame($definition['name'], $pls->name);
|
||||||
|
$this->assertSame($definition['desc'], $pls->desc);
|
||||||
|
$this->assertSame($definition['url'], $pls->url);
|
||||||
|
$this->assertSame($definition['src'], $pls->src);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет успешное создание объекта при отсутствии значений опциональных параметров
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
*/
|
||||||
|
public function testOptionalParams(): void
|
||||||
|
{
|
||||||
|
$code = 'foo';
|
||||||
|
$ini = new IniFile($this->makeIni());
|
||||||
|
$definition = $ini->playlist($code);
|
||||||
|
unset($definition['name']);
|
||||||
|
unset($definition['desc']);
|
||||||
|
unset($definition['src']);
|
||||||
|
|
||||||
|
$pls = new Playlist($code, $definition);
|
||||||
|
|
||||||
|
$this->assertSame($code, $pls->code);
|
||||||
|
$this->assertNull($pls->name);
|
||||||
|
$this->assertNull($pls->desc);
|
||||||
|
$this->assertSame($definition['url'], $pls->url);
|
||||||
|
$this->assertNull($pls->src);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws FileReadException
|
||||||
|
* @throws IniParsingException
|
||||||
|
* @throws PlaylistNotFoundException
|
||||||
|
*/
|
||||||
|
public function testPlaylistWithoutUrlException(): void
|
||||||
|
{
|
||||||
|
$code = 'foo';
|
||||||
|
$this->expectException(PlaylistWithoutUrlException::class);
|
||||||
|
$this->expectExceptionMessage("Плейлист '{$code}' имеет неверный url");
|
||||||
|
$ini = new IniFile($this->makeIni());
|
||||||
|
$definition = $ini->playlist($code);
|
||||||
|
unset($definition['url']);
|
||||||
|
|
||||||
|
new Playlist($code, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetCheckResult(): void
|
||||||
|
{
|
||||||
|
$code = 'foo';
|
||||||
|
$ini = new IniFile($this->makeIni());
|
||||||
|
|
||||||
|
$definition = $ini->playlist($code);
|
||||||
|
$pls = new Playlist($code, $definition);
|
||||||
|
|
||||||
|
$redis = $this->createPartialMock(Redis::class, ['get']);
|
||||||
|
$redis->expects($this->once())->method('get')->with($code)->willReturn(null);
|
||||||
|
|
||||||
|
|
||||||
|
$pls->getCheckResult();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/FixtureHandler.php
Normal file
109
tests/FixtureHandler.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?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 Tests;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
trait FixtureHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Вычитывает содержимое файла строкой
|
||||||
|
*
|
||||||
|
* @param string $filepath
|
||||||
|
* @return string
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function loadFixtureContent(string $filepath): string
|
||||||
|
{
|
||||||
|
$filepath = static::buildFixtureFilePath($filepath);
|
||||||
|
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
|
||||||
|
|
||||||
|
return (string) file_get_contents($filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычитывает .json файл в php-массив
|
||||||
|
*
|
||||||
|
* @param string $filepath
|
||||||
|
* @param string|null $key
|
||||||
|
* @return array
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
protected function loadJsonFixture(string $filepath, ?string $key = null): array
|
||||||
|
{
|
||||||
|
$contents = $this->loadFixtureContent($filepath);
|
||||||
|
$contents = json_decode($contents, true);
|
||||||
|
|
||||||
|
return $key ? $contents[$key] : $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подгружает фиксутуру для тестов
|
||||||
|
*
|
||||||
|
* @param string $filepath Имя файла или путь до него внутри tests/Fixtures/...
|
||||||
|
* @return mixed
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
protected function loadPhpFixture(string $filepath): mixed
|
||||||
|
{
|
||||||
|
$filepath = static::buildFixtureFilePath($filepath);
|
||||||
|
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
|
||||||
|
|
||||||
|
return require $filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет указанные сырые данные в виде файла с данными
|
||||||
|
* для использования в качестве фикстуры в тестах.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* 0. предполагается при подготовке к написанию теста
|
||||||
|
* 1. вызвать `makeFixture()`, передав нужные данные
|
||||||
|
* 2. найти файл в `tests/Fixtures/...`, проверить корректность
|
||||||
|
* 3. подгрузить фикстуру и замокать вызов курсорной БД-функции
|
||||||
|
* ```
|
||||||
|
* $fixture = this->loadFixture(...);
|
||||||
|
* $this->mockDbCursor(...)->andReturn($fixture);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param array|Collection $data Данные для сохранения в фикстуре
|
||||||
|
* @param string $name Имя файла или путь до него внутри tests/Fixtures/...
|
||||||
|
* @param bool $is_json Сохранить в json-формате
|
||||||
|
*/
|
||||||
|
public static function saveFixture(mixed $data, string $name, bool $is_json = false): void
|
||||||
|
{
|
||||||
|
$data = match (true) {
|
||||||
|
$data instanceof Traversable => iterator_to_array($data),
|
||||||
|
default => $data,
|
||||||
|
};
|
||||||
|
if ($is_json) {
|
||||||
|
$string = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
$ext = 'json';
|
||||||
|
} else {
|
||||||
|
$string = var_export($data, true);
|
||||||
|
$string = preg_replace("/(\n\\s+)?array\\s\\(/", '[', $string); // конвертим в короткий синтаксис
|
||||||
|
$string = str_replace([')', 'NULL'], [']', 'null'], $string); // остатки
|
||||||
|
$string = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$string};\n"; // добавляем заголовок для файла
|
||||||
|
$ext = 'php';
|
||||||
|
}
|
||||||
|
$filepath = __DIR__ . "/Fixtures/{$name}.{$ext}";
|
||||||
|
!file_exists($filepath) && @mkdir(dirname($filepath), recursive: true);
|
||||||
|
$filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath);
|
||||||
|
file_put_contents($filepath, $string);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function buildFixtureFilePath(string $filepath): string
|
||||||
|
{
|
||||||
|
$filepath = trim(ltrim($filepath, DIRECTORY_SEPARATOR));
|
||||||
|
|
||||||
|
return __DIR__ . DIRECTORY_SEPARATOR . 'Fixtures' . DIRECTORY_SEPARATOR . $filepath;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
tests/Fixtures/broken.ini
Normal file
5
tests/Fixtures/broken.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
n]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
11
tests/Fixtures/playlists.ini
Normal file
11
tests/Fixtures/playlists.ini
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[p1]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
|
|
||||||
|
[z2]
|
||||||
|
name=
|
||||||
|
desc=
|
||||||
|
url=
|
||||||
|
src=
|
||||||
@@ -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 %}
|
||||||
@@ -101,7 +96,7 @@
|
|||||||
<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>
|
||||||
<span onclick="copyPlaylistUrl('{{ playlist.code }}')"
|
<span onclick="copyPlaylistUrl('{{ playlist.code }}')"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@@ -243,26 +238,11 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="my-3">
|
<p class="my-3">
|
||||||
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
|
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
|
||||||
Если вы являетесь правообладателем и считаете, что сведения на этой странице затрагивают ваши
|
Если вы являетесь правообладателем и считаете, что сведения на этой странице затрагивают ваши права, вы можете направить конфиденциальное уведомление на адрес abuse@m3u.su.
|
||||||
права, вот какие меры вы можете предпринять прямо сейчас:
|
</p>
|
||||||
|
<p class="my-3">
|
||||||
|
Плейлисты, нарушающие законодательство, удаляются с сайта окончательно по факту обращения от правообладателя.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
создать pull-request в открытом репозитории проекта с удалением данных о плейлисте из файла
|
|
||||||
<a href="{{ config('app.repo_url') }}/playlists/src/branch/master/playlists.ini"
|
|
||||||
target="_blank"
|
|
||||||
>playlists.ini</a>, указав в комментарии к коммиту юридически значимую информацию;
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{{ config('app.repo_url') }}/playlists/issues/new" target="_blank">создать
|
|
||||||
публичное обращение</a> в открытом репозитории проекта, указав юридически значимую
|
|
||||||
информацию;
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
направить конфиденциальное уведомление на адрес:
|
|
||||||
<a href="mailto:abuse@m3u.su" target="_blank">abuse@m3u.su</a>.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,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">
|
||||||
@@ -386,175 +364,166 @@
|
|||||||
<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="
HTTP: {{ channel.status ?: '(неизвестно)' }}
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">
|
|
||||||
{% if channel.isOnline is same as (true) %}
|
|
||||||
<ion-icon name="radio-button-on-outline"
|
|
||||||
class="cursor-help me-1 text-success"
|
|
||||||
title="Состояние: онлайн (возможно, работает прямо сейчас)"
|
|
||||||
></ion-icon>
|
|
||||||
{% else %}
|
|
||||||
<ion-icon name="radio-button-on-outline"
|
|
||||||
class="cursor-help me-1 text-danger"
|
|
||||||
title="Состояние: оффлайн (не работал в момент проверки или не удалось проверить)"
|
|
||||||
></ion-icon>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if "adult" in channel.tags %}
|
|
||||||
<span class="badge small bg-warning text-dark"
|
|
||||||
title="Канал для взрослых!"
|
|
||||||
>18+</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if channel.hasToken is same as (true) %}
|
|
||||||
<span class="cursor-help badge small bg-info text-dark"
|
|
||||||
title="Может быть нестабилен"
|
|
||||||
>
|
|
||||||
<ion-icon name="paw"></ion-icon>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<span class="chname">{{ channel.title }}</span>
|
|
||||||
|
|
||||||
<div class="text-secondary small">
|
|
||||||
{% if (channel.attributes['tvg-id']) %}
|
|
||||||
<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help">
|
|
||||||
<ion-icon name="star-outline" class="me-1"></ion-icon> {{ channel.attributes['tvg-id'] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if (channel.contentType != null) %}
|
|
||||||
<div title="Тип контента (mime-type)" class="cursor-help">
|
|
||||||
<ion-icon name="eye-outline" class="me-1"></ion-icon> {{ channel.contentType }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if channel.tags|length > 0 %}
|
|
||||||
<ion-icon name="pricetag-outline"
|
|
||||||
class="cursor-help me-1"
|
|
||||||
title="Теги"
|
|
||||||
></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 %}
|
||||||
<script src="/js/list.min.js"></script>
|
{% if playlist.isOnline is same as (true) %}
|
||||||
<script>
|
<script src="/js/list.min.js"></script>
|
||||||
const options = {
|
<script>
|
||||||
valueNames: [
|
function getChannelTemplate(channel) {
|
||||||
'chname',
|
const httpCode = channel.status ?? '(неизвестно)'
|
||||||
{data: ['online', 'group', 'tag', 'chtags']}
|
const errText = !!channel.error
|
||||||
],
|
? channel.error.replace(/&/g, '&')
|
||||||
};
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
: '(нет)'
|
||||||
|
|
||||||
const list = new List('chlist', options)
|
const logoUrl = channel.attributes['tvg-logo'] ?? '/no-tvg-logo.png'
|
||||||
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
|
const logoHint = !!channel.attributes['tvg-logo']
|
||||||
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
? `Логотип канала '${channel.title}'`
|
||||||
|
: `Нет логотипа для канала '${channel.title}'`
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
|
||||||
const alert = document.getElementById("toomuchalert");
|
const statusIconHint = channel.isOnline
|
||||||
!!alert && alert.remove()
|
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
|
||||||
});
|
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
|
||||||
|
|
||||||
function savePlaylist() {
|
const adultIcon = channel.tags.indexOf('adult') !== -1
|
||||||
const link = document.createElement("a");
|
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
|
||||||
const content = document.getElementById("m3u-raw").value
|
: ''
|
||||||
const file = new Blob([content], { type: 'text/plain' });
|
|
||||||
link.href = URL.createObjectURL(file);
|
|
||||||
link.download = "{{ playlist.code }}.m3u8";
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGroup() {
|
const pawIcon = channel.hasToken
|
||||||
document.getElementById('groupSelector').value = 'all'
|
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
|
||||||
updateFilter()
|
: ''
|
||||||
}
|
|
||||||
|
|
||||||
function resetSearch() {
|
const tvgId = !!channel.attributes['tvg-id']
|
||||||
list.search('')
|
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon> ${channel.attributes['tvg-id']}</div>`
|
||||||
document.getElementById('search-field').value = ''
|
: ``
|
||||||
document.getElementById('chfAll').checked = true
|
|
||||||
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
|
|
||||||
updateFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFilter() {
|
const mimeType = !!channel.contentType
|
||||||
const groupHash = document.getElementById('groupSelector')?.value ?? 'all';
|
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon> ${channel.contentType}</div>`
|
||||||
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
|
: ``
|
||||||
const tagsSelected = []
|
|
||||||
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
|
const tags = channel.tags.length > 0
|
||||||
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon> `
|
||||||
switch (activeType) {
|
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
|
||||||
case 'chfAll':
|
: ``
|
||||||
list.filter(item => {
|
|
||||||
const chTags = item.values().chtags.split('|');
|
return `<tr class="chrow" title="
HTTP: ${httpCode}
Error: ${errText}">
|
||||||
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
|
<td class="chlogo text-center">
|
||||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
|
||||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
</td>
|
||||||
return isGroupValid && hasValidTags;
|
<td class="text-break">
|
||||||
})
|
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
|
||||||
break
|
${adultIcon}
|
||||||
case 'chfOnline':
|
${pawIcon}
|
||||||
list.filter(item => {
|
${channel.title}
|
||||||
const isOnline = item.values().online === '1'
|
<div class="text-secondary small">
|
||||||
const chTags = item.values().chtags.split('|');
|
${tvgId}
|
||||||
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
|
${mimeType}
|
||||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
${tags}
|
||||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
</div>
|
||||||
return isGroupValid && isOnline && hasValidTags
|
</td>
|
||||||
})
|
</tr>`
|
||||||
break
|
|
||||||
case 'chfOffline':
|
|
||||||
list.filter(item => {
|
|
||||||
const isOffline = item.values().online === '0'
|
|
||||||
const chTags = item.values().chtags.split('|');
|
|
||||||
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
|
|
||||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
|
||||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
|
||||||
return isGroupValid && isOffline && hasValidTags
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
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)
|
||||||
|
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const alert = document.getElementById("chListLoading")
|
||||||
|
!!alert && alert.remove()
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function savePlaylist() {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const content = document.getElementById("m3u-raw").value
|
||||||
|
const file = new Blob([content], { type: 'text/plain' });
|
||||||
|
link.href = URL.createObjectURL(file);
|
||||||
|
link.download = "{{ playlist.code }}.m3u8";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGroup() {
|
||||||
|
document.getElementById('groupSelector').value = 'all'
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
list.search('')
|
||||||
|
document.getElementById('search-field').value = ''
|
||||||
|
document.getElementById('chfAll').checked = true
|
||||||
|
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilter() {
|
||||||
|
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
|
||||||
|
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
|
||||||
|
const tagsSelected = []
|
||||||
|
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
|
||||||
|
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
||||||
|
switch (activeType) {
|
||||||
|
case 'chfAll':
|
||||||
|
list.filter(item => {
|
||||||
|
const chTags = item.values().tags
|
||||||
|
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||||
|
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||||
|
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||||
|
return isGroupValid && hasValidTags;
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'chfOnline':
|
||||||
|
list.filter(item => {
|
||||||
|
const isOnline = item.values().isOnline
|
||||||
|
const chTags = item.values().tags
|
||||||
|
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||||
|
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||||
|
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||||
|
return isGroupValid && isOnline && hasValidTags
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'chfOffline':
|
||||||
|
list.filter(item => {
|
||||||
|
const isOffline = !item.values().isOnline
|
||||||
|
const chTags = item.values().tags
|
||||||
|
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||||
|
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||||
|
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||||
|
return isGroupValid && isOffline && hasValidTags
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
<meta name="description" content="{% block metadescription %}{% endblock %}">
|
||||||
<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 name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</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>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="IPTV Плейлисты" />
|
<meta name="apple-mobile-web-app-title" content="{{ config('app.title') }}" />
|
||||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
<meta name="msapplication-TileColor" content="#00aba9">
|
<meta name="msapplication-TileColor" content="#00aba9">
|
||||||
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">
|
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">
|
||||||
@@ -134,9 +134,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
|
||||||
{% block footer %}{% endblock %}
|
|
||||||
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
<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="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@@ -145,6 +142,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
const toastEl = document.getElementById('clipboardToast');
|
const toastEl = document.getElementById('clipboardToast');
|
||||||
|
|||||||
Reference in New Issue
Block a user