From d8a48520fe1e8a43199d75e1bc22388ec85cfc6d Mon Sep 17 00:00:00 2001 From: Divarion-D Date: Tue, 21 Apr 2026 14:52:46 +0300 Subject: [PATCH] feat(stream): migrate to vendored M3uParser and add parser/import tests - Replace legacy in-project M3U parser with vendored Gemorroj/M3uParser - Initialize parser via bootstrap with proper namespacing - Remove obsolete src/domain/Stream/M3UEntry.php implementation - Update stream import/review mapping: - use logo as fallback when tvg-logo is missing - Add third-party compliance files for vendored parser: - ATTRIBUTION.md - LGPL LICENSE - Add full M3uParser unit test suite: - tag parsing - parser behavior - fixtures - Add StreamService integration tests for M3U parsing/import flow - Update test bootstrap: - add igbinary_serialize / igbinary_unserialize polyfills - explicitly load M3uParser bootstrap and custom test tag class --- src/core/Parsing/M3uParser/ATTRIBUTION.md | 10 + src/core/Parsing/M3uParser/LICENSE | 164 +++++ src/core/Parsing/M3uParser/bootstrap.php | 23 + src/core/Parsing/M3uParser/src/Exception.php | 8 + src/core/Parsing/M3uParser/src/M3uData.php | 20 + src/core/Parsing/M3uParser/src/M3uEntry.php | 59 ++ src/core/Parsing/M3uParser/src/M3uParser.php | 118 ++++ src/core/Parsing/M3uParser/src/Tag/ExtAlb.php | 54 ++ .../M3uParser/src/Tag/ExtAlbumArtUrl.php | 54 ++ src/core/Parsing/M3uParser/src/Tag/ExtArt.php | 54 ++ .../Parsing/M3uParser/src/Tag/ExtGenre.php | 54 ++ src/core/Parsing/M3uParser/src/Tag/ExtGrp.php | 54 ++ src/core/Parsing/M3uParser/src/Tag/ExtImg.php | 55 ++ src/core/Parsing/M3uParser/src/Tag/ExtInf.php | 85 +++ .../Parsing/M3uParser/src/Tag/ExtLogo.php | 53 ++ .../M3uParser/src/Tag/ExtTagInterface.php | 11 + .../Parsing/M3uParser/src/Tag/ExtTitle.php | 54 ++ src/core/Parsing/M3uParser/src/Tag/ExtTv.php | 102 +++ .../Parsing/M3uParser/src/Tag/ExtVlcOpt.php | 68 ++ .../Parsing/M3uParser/src/Tag/Playlist.php | 54 ++ .../M3uParser/src/TagAttributesTrait.php | 74 +++ .../M3uParser/src/TagsManagerTrait.php | 83 +++ src/domain/Stream/M3UEntry.php | 579 ------------------ src/domain/Stream/StreamService.php | 6 +- src/public/Views/admin/review.php | 2 +- tests/.tmp/bin/fake_bin.sh | 2 + tests/Unit/M3uParser/ExceptionTest.php | 16 + tests/Unit/M3uParser/ExtCustomTag.php | 58 ++ tests/Unit/M3uParser/M3uDataTest.php | 111 ++++ tests/Unit/M3uParser/M3uParserTest.php | 104 ++++ tests/Unit/M3uParser/Tag/ExtAlbTest.php | 56 ++ .../Unit/M3uParser/Tag/ExtAlbumArtUrlTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtArtTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtGenreTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtGrpTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtImgTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtInfTest.php | 197 ++++++ tests/Unit/M3uParser/Tag/ExtLogoTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtTitleTest.php | 56 ++ tests/Unit/M3uParser/Tag/ExtTvTest.php | 62 ++ tests/Unit/M3uParser/Tag/ExtVlcOptTest.php | 58 ++ tests/Unit/M3uParser/Tag/PlaylistTest.php | 56 ++ .../Unit/M3uParser/TagAttributesTraitTest.php | 44 ++ tests/Unit/M3uParser/TagsManagerTraitTest.php | 42 ++ tests/Unit/M3uParser/fixtures/combined.m3u | 5 + tests/Unit/M3uParser/fixtures/comment.m3u | 4 + tests/Unit/M3uParser/fixtures/customtag.m3u | 3 + tests/Unit/M3uParser/fixtures/extalb.m3u | 4 + .../M3uParser/fixtures/extalbumarturl.m3u | 4 + tests/Unit/M3uParser/fixtures/extart.m3u | 4 + tests/Unit/M3uParser/fixtures/extgenre.m3u | 4 + tests/Unit/M3uParser/fixtures/extgrp.m3u | 4 + tests/Unit/M3uParser/fixtures/extimg.m3u | 3 + tests/Unit/M3uParser/fixtures/extinf.m3u | 18 + tests/Unit/M3uParser/fixtures/extlogo.m3u | 3 + tests/Unit/M3uParser/fixtures/extm3u.m3u | 3 + tests/Unit/M3uParser/fixtures/exttitle.m3u | 4 + tests/Unit/M3uParser/fixtures/exttv.m3u | 4 + tests/Unit/M3uParser/fixtures/extvlcopt.m3u | 4 + tests/Unit/M3uParser/fixtures/issue23.m3u | 7 + tests/Unit/M3uParser/fixtures/notags.m3u | 1 + tests/Unit/M3uParser/fixtures/playlist.m3u | 4 + tests/Unit/StreamServiceParseM3UTest.php | 128 ++++ tests/bootstrap.php | 13 + 64 files changed, 2737 insertions(+), 583 deletions(-) create mode 100644 src/core/Parsing/M3uParser/ATTRIBUTION.md create mode 100644 src/core/Parsing/M3uParser/LICENSE create mode 100644 src/core/Parsing/M3uParser/bootstrap.php create mode 100644 src/core/Parsing/M3uParser/src/Exception.php create mode 100644 src/core/Parsing/M3uParser/src/M3uData.php create mode 100644 src/core/Parsing/M3uParser/src/M3uEntry.php create mode 100644 src/core/Parsing/M3uParser/src/M3uParser.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtAlb.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtAlbumArtUrl.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtArt.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtGenre.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtGrp.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtImg.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtInf.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtLogo.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtTagInterface.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtTitle.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtTv.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/ExtVlcOpt.php create mode 100644 src/core/Parsing/M3uParser/src/Tag/Playlist.php create mode 100644 src/core/Parsing/M3uParser/src/TagAttributesTrait.php create mode 100644 src/core/Parsing/M3uParser/src/TagsManagerTrait.php delete mode 100644 src/domain/Stream/M3UEntry.php create mode 100644 tests/.tmp/bin/fake_bin.sh create mode 100644 tests/Unit/M3uParser/ExceptionTest.php create mode 100644 tests/Unit/M3uParser/ExtCustomTag.php create mode 100644 tests/Unit/M3uParser/M3uDataTest.php create mode 100644 tests/Unit/M3uParser/M3uParserTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtAlbTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtAlbumArtUrlTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtArtTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtGenreTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtGrpTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtImgTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtInfTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtLogoTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtTitleTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtTvTest.php create mode 100644 tests/Unit/M3uParser/Tag/ExtVlcOptTest.php create mode 100644 tests/Unit/M3uParser/Tag/PlaylistTest.php create mode 100644 tests/Unit/M3uParser/TagAttributesTraitTest.php create mode 100644 tests/Unit/M3uParser/TagsManagerTraitTest.php create mode 100644 tests/Unit/M3uParser/fixtures/combined.m3u create mode 100644 tests/Unit/M3uParser/fixtures/comment.m3u create mode 100644 tests/Unit/M3uParser/fixtures/customtag.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extalb.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extalbumarturl.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extart.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extgenre.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extgrp.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extimg.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extinf.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extlogo.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extm3u.m3u create mode 100644 tests/Unit/M3uParser/fixtures/exttitle.m3u create mode 100644 tests/Unit/M3uParser/fixtures/exttv.m3u create mode 100644 tests/Unit/M3uParser/fixtures/extvlcopt.m3u create mode 100644 tests/Unit/M3uParser/fixtures/issue23.m3u create mode 100644 tests/Unit/M3uParser/fixtures/notags.m3u create mode 100644 tests/Unit/M3uParser/fixtures/playlist.m3u create mode 100644 tests/Unit/StreamServiceParseM3UTest.php diff --git a/src/core/Parsing/M3uParser/ATTRIBUTION.md b/src/core/Parsing/M3uParser/ATTRIBUTION.md new file mode 100644 index 00000000..1b26d708 --- /dev/null +++ b/src/core/Parsing/M3uParser/ATTRIBUTION.md @@ -0,0 +1,10 @@ +# Third-Party Attribution: Gemorroj M3uParser + +- Library: Gemorroj/M3uParser +- Upstream: +- Original Author: Gemorroj +- Copyright: Copyright (c) Gemorroj +- License: LGPL-3.0-or-later +- Source Snapshot: Imported from folder M3uParser-6.1.0 + +This project vendors the library source under src/core/Parsing/M3uParser for non-Composer runtime compatibility. diff --git a/src/core/Parsing/M3uParser/LICENSE b/src/core/Parsing/M3uParser/LICENSE new file mode 100644 index 00000000..2fd9866c --- /dev/null +++ b/src/core/Parsing/M3uParser/LICENSE @@ -0,0 +1,164 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/src/core/Parsing/M3uParser/bootstrap.php b/src/core/Parsing/M3uParser/bootstrap.php new file mode 100644 index 00000000..f2dad614 --- /dev/null +++ b/src/core/Parsing/M3uParser/bootstrap.php @@ -0,0 +1,23 @@ + */ +class M3uData extends \ArrayIterator implements \Stringable { + use TagAttributesTrait; + + public function __toString(): string { + $out = \rtrim('#EXTM3U ' . $this->getAttributesString()) . "\n"; + + foreach ($this as $entry) { + $out .= $entry . "\n"; + } + + return \rtrim($out); + } +} diff --git a/src/core/Parsing/M3uParser/src/M3uEntry.php b/src/core/Parsing/M3uParser/src/M3uEntry.php new file mode 100644 index 00000000..21a01a1d --- /dev/null +++ b/src/core/Parsing/M3uParser/src/M3uEntry.php @@ -0,0 +1,59 @@ +getExtTags() as $extTag) { + $out .= $extTag . $this->lineDelimiter; + } + + $out .= $this->getPath(); + + return \rtrim($out); + } + + /** + * @return ExtTagInterface[] + */ + public function getExtTags(): array { + return $this->extTags; + } + + public function addExtTag(ExtTagInterface $extTag): self { + $this->extTags[] = $extTag; + + return $this; + } + + /** + * Remove all previously defined tags. + */ + public function clearExtTags(): self { + $this->extTags = []; + + return $this; + } + + public function getPath(): string { + return $this->path; + } + + public function setPath(string $path): self { + $this->path = $path; + + return $this; + } +} diff --git a/src/core/Parsing/M3uParser/src/M3uParser.php b/src/core/Parsing/M3uParser/src/M3uParser.php new file mode 100644 index 00000000..3fa7e230 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/M3uParser.php @@ -0,0 +1,118 @@ +parse($str); + } + + /** + * Parse m3u string. + */ + public function parse(string $str): M3uData { + $this->removeBom($str); + + $data = $this->createM3uData(); + $lines = \explode("\n", $str); + + for ($i = 0, $l = \count($lines); $i < $l; ++$i) { + $lineStr = \trim($lines[$i]); + if ('' === $lineStr || $this->isComment($lineStr)) { + continue; + } + + if ($this->isExtM3u($lineStr)) { + $tmp = \trim(\substr($lineStr, 7)); + if ($tmp) { + $data->initAttributes($tmp); + } + + continue; + } + + $data->append($this->parseLine($i, $lines)); + } + + return $data; + } + + protected function createM3uEntry(): M3uEntry { + return new M3uEntry(); + } + + protected function createM3uData(): M3uData { + return new M3uData(); + } + + /** + * Parse one line. + * + * @param string[] $linesStr + */ + protected function parseLine(int &$lineNumber, array $linesStr): M3uEntry { + $entry = $this->createM3uEntry(); + + for ($l = \count($linesStr); $lineNumber < $l; ++$lineNumber) { + $nextLineStr = $linesStr[$lineNumber]; + $nextLineStr = \trim($nextLineStr); + + if ('' === $nextLineStr || $this->isComment($nextLineStr) || $this->isExtM3u($nextLineStr)) { + continue; + } + + $matched = false; + foreach ($this->getTags() as $availableTag) { + if ($availableTag::isMatch($nextLineStr)) { + $matched = true; + $entry->addExtTag(new $availableTag($nextLineStr)); + + break; + } + } + + if (!$matched) { + $entry->setPath($nextLineStr); + + break; + } + } + + return $entry; + } + + protected function removeBom(string &$str): void { + if (\str_starts_with($str, "\xEF\xBB\xBF")) { + $str = \substr($str, 3); + } + } + + protected function isExtM3u(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTM3U'); + } + + protected function isComment(string $lineStr): bool { + $matched = false; + foreach ($this->getTags() as $availableTag) { + if ($availableTag::isMatch($lineStr)) { + $matched = true; + + break; + } + } + + return !$matched && \str_starts_with($lineStr, '#') && !$this->isExtM3u($lineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtAlb.php b/src/core/Parsing/M3uParser/src/Tag/ExtAlb.php new file mode 100644 index 00000000..2d176b0c --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtAlb.php @@ -0,0 +1,54 @@ +make($lineStr); + } + } + + public function __toString(): string { + return '#EXTALB:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTALB:'); + } + + protected function make(string $lineStr): void { + /* +EXTALB format: +#EXTALB: +example: +#EXTALB:soma album + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTALB:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtAlbumArtUrl.php b/src/core/Parsing/M3uParser/src/Tag/ExtAlbumArtUrl.php new file mode 100644 index 00000000..b309b2f0 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtAlbumArtUrl.php @@ -0,0 +1,54 @@ +make($lineStr); + } + } + + public function __toString(): string { + return '#EXTALBUMARTURL:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTALBUMARTURL:'); + } + + protected function make(string $lineStr): void { + /* +EXTALBUMARTURL format: +#EXTALBUMARTURL: +example: +#EXTALBUMARTURL:https://store.example.com/download/A32X5yz-1.jpg + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTALBUMARTURL:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtArt.php b/src/core/Parsing/M3uParser/src/Tag/ExtArt.php new file mode 100644 index 00000000..cf76a5ea --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtArt.php @@ -0,0 +1,54 @@ +make($lineStr); + } + } + + public function __toString(): string { + return '#EXTART:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTART:'); + } + + protected function make(string $lineStr): void { + /* +EXTART format: +#EXTART: +example: +#EXTART:Гражданская оборона + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTART:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtGenre.php b/src/core/Parsing/M3uParser/src/Tag/ExtGenre.php new file mode 100644 index 00000000..c1be002c --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtGenre.php @@ -0,0 +1,54 @@ +make($lineStr); + } + } + + public function __toString(): string { + return '#EXTGENRE:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTGENRE:'); + } + + protected function make(string $lineStr): void { + /* +EXTGENRE format: +#EXTGENRE: +example: +#EXTGENRE:Rock + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTGENRE:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtGrp.php b/src/core/Parsing/M3uParser/src/Tag/ExtGrp.php new file mode 100644 index 00000000..54aae8c5 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtGrp.php @@ -0,0 +1,54 @@ +make($lineStr); + } + } + + public function __toString(): string { + return '#EXTGRP:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTGRP:'); + } + + protected function make(string $lineStr): void { + /* +EXTGRP format: +#EXTGRP: +example: +#EXTGRP:Rock + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTGRP:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtImg.php b/src/core/Parsing/M3uParser/src/Tag/ExtImg.php new file mode 100644 index 00000000..a97d44c3 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtImg.php @@ -0,0 +1,55 @@ +makeData($lineStr); + } + } + + public function __toString(): string { + return '#EXTIMG:' . $this->getValue(); + } + + public function getValue(): string { + return $this->value; + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTIMG:'); + } + + protected function makeData(string $lineStr): void { + /* +EXTIMG format: +#EXTIMG:logo +example: +#EXTIMG:http://cdn1.siol.tv/logo/93x78/slo2.png + */ + + $tmp = \substr($lineStr, \strlen('#EXTIMG:')); + $value = \trim($tmp); + + $this->setValue($value); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtInf.php b/src/core/Parsing/M3uParser/src/Tag/ExtInf.php new file mode 100644 index 00000000..f5359ea9 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtInf.php @@ -0,0 +1,85 @@ +make($lineStr); + } + } + + public function __toString(): string { + $attributesString = $this->getAttributesString(); + + return '#EXTINF:' . $this->getDuration() . ('' === $attributesString ? '' : ' ' . $attributesString) . ',' . $this->getTitle(); + } + + public function setTitle(string $title): self { + $this->title = $title; + + return $this; + } + + public function getTitle(): string { + return $this->title; + } + + public function setDuration(float|int $duration): self { + $this->duration = (float) $duration; + + return $this; + } + + public function getDuration(): float { + return $this->duration; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTINF:'); + } + + /** + * @see http://l189-238-14.cn.ru/api-doc/m3u-extending.html + */ + protected function make(string $lineStr): void { + /* +EXTINF format: +#EXTINF: [], +example: +#EXTINF:-1 tvg-name=Первый_HD tvg-logo="Первый канал" deinterlace=4 group-title="Эфирные каналы",Первый канал HD + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTINF:')); + $dataLineStr = \trim($dataLineStr); + + // Parse duration and title with regex + \preg_match('/^(-?[\d\.]+)\s*(?:(?:[^=]+=["][^"]*["])|(?:[^=]+=[^ ]*))*\s*,\s*(.*)$/', $dataLineStr, $matches); + + $this->setDuration((float) $matches[1]); + $this->setTitle(\trim($matches[2])); + + // Attributes are remaining string after remove duration and title + $attributes = \preg_replace('/^' . \preg_quote($matches[1], '/') . '(.*)' . \preg_quote($matches[2], '/') . '$/', '$1', $dataLineStr); + + $splitAttributes = \explode(' ', $attributes, 2); + + if (isset($splitAttributes[1]) && $trimmedAttributes = \trim($splitAttributes[1])) { + $this->initAttributes($trimmedAttributes); + } + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtLogo.php b/src/core/Parsing/M3uParser/src/Tag/ExtLogo.php new file mode 100644 index 00000000..dd298367 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtLogo.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +/** + * @see https://github.com/Gemorroj/M3uParser/issues/20 + */ +class ExtLogo implements ExtTagInterface { + private string $logo; + + /** + * #EXTLOGO:http://cdn1.siol.tv/logo/93x78/slo2.png. + */ + public function __construct(?string $lineStr = null) { + if (null !== $lineStr) { + $this->makeData($lineStr); + } + } + + public function __toString(): string { + return '#EXTLOGO:' . $this->getValue(); + } + + public function getValue(): string { + return $this->logo; + } + + public function setValue(string $logo): self { + $this->logo = $logo; + + return $this; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTLOGO:'); + } + + protected function makeData(string $lineStr): void { + /* +EXTLOGO format: +#EXTLOGO:logo +example: +#EXTLOGO:http://cdn1.siol.tv/logo/93x78/slo2.png + */ + + $tmp = \substr($lineStr, \strlen('#EXTLOGO:')); + $logo = \trim($tmp); + + $this->setValue($logo); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtTagInterface.php b/src/core/Parsing/M3uParser/src/Tag/ExtTagInterface.php new file mode 100644 index 00000000..7c049159 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtTagInterface.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +interface ExtTagInterface extends \Stringable { + public function __construct(?string $lineStr = null); + + public static function isMatch(string $lineStr): bool; +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtTitle.php b/src/core/Parsing/M3uParser/src/Tag/ExtTitle.php new file mode 100644 index 00000000..fcaa43bf --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtTitle.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +/** + * Title for the tracks included in the playlist. + * + * @see https://github.com/Gemorroj/M3uParser/issues/33 + */ +class ExtTitle implements ExtTagInterface { + private string $value; + + /** + * #EXTTITLE:super-song. + */ + public function __construct(?string $lineStr = null) { + if (null !== $lineStr) { + $this->make($lineStr); + } + } + + public function __toString(): string { + return '#EXTTITLE:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTTITLE:'); + } + + protected function make(string $lineStr): void { + /* +EXTTITLE format: +#EXTTITLE:<value> +example: +#EXTTITLE:name + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTTITLE:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtTv.php b/src/core/Parsing/M3uParser/src/Tag/ExtTv.php new file mode 100644 index 00000000..b2914efd --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtTv.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +/** + * Defines language and groups TV programs are in. + * + * @see https://github.com/Gemorroj/M3uParser/issues/5 + */ +class ExtTv implements ExtTagInterface { + /** + * @var string[] + */ + private array $tags = []; + private string $language; + private string $xmlTvId; + private ?string $iconUrl = null; + + /** + * #EXTTV:nacionalni,hd;slovenski;SLO1;http://cdn1.siol.tv/logo/93x78/slo2.png. + */ + public function __construct(?string $lineStr = null) { + if (null !== $lineStr) { + $this->makeData($lineStr); + } + } + + public function __toString(): string { + return '#EXTTV:' . \implode(',', $this->getTags()) . ';' . $this->getLanguage() . ';' . $this->getXmlTvId() . ($this->getIconUrl() ? ';' . $this->getIconUrl() : ''); + } + + /** + * @return string[] + */ + public function getTags(): array { + return $this->tags; + } + + /** + * @param string[] $tags + */ + public function setTags(array $tags): self { + $this->tags = $tags; + + return $this; + } + + public function getLanguage(): string { + return $this->language; + } + + public function setLanguage(string $language): self { + $this->language = $language; + + return $this; + } + + public function getXmlTvId(): string { + return $this->xmlTvId; + } + + public function setXmlTvId(string $xmlTvId): self { + $this->xmlTvId = $xmlTvId; + + return $this; + } + + public function getIconUrl(): ?string { + return $this->iconUrl; + } + + public function setIconUrl(?string $iconUrl): self { + $this->iconUrl = $iconUrl; + + return $this; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTTV:'); + } + + protected function makeData(string $lineStr): void { + /* +EXTTV format: +#EXTTV:tag[,tag,tag...];language;XMLTV id[;icon URL] +example: +#EXTTV:nacionalni,hd;slovenski;SLO1;http://cdn1.siol.tv/logo/93x78/slo2.png + */ + + $tmp = \substr($lineStr, \strlen('#EXTTV:')); + $split = \explode(';', $tmp, 4); + + $this->setTags(\array_map('trim', \explode(',', $split[0]))); + $this->setLanguage(\trim($split[1])); + $this->setXmlTvId(\trim($split[2])); + if (isset($split[3])) { + $this->setIconUrl(\trim($split[3])); + } + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/ExtVlcOpt.php b/src/core/Parsing/M3uParser/src/Tag/ExtVlcOpt.php new file mode 100644 index 00000000..4ab1a3e7 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/ExtVlcOpt.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +/** + * VLC specific attribute. + * + * @see https://github.com/Gemorroj/M3uParser/issues/27 + */ +class ExtVlcOpt implements ExtTagInterface { + private string $key; + private string $value; + + /** + * #EXTVLCOPT:http-user-agent=Mozilla/5.0 (Windows NT 6.1; rv:61.0) Gecko/20100101 Firefox/61.0. + */ + public function __construct(?string $lineStr = null) { + if (null !== $lineStr) { + $this->make($lineStr); + } + } + + public function __toString(): string { + return '#EXTVLCOPT:' . $this->getKey() . '=' . $this->getValue(); + } + + public function setKey(string $key): self { + $this->key = $key; + + return $this; + } + + public function getKey(): string { + return $this->key; + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#EXTVLCOPT:'); + } + + protected function make(string $lineStr): void { + /* +EXTVLCOPT format: +#EXTVLCOPT:<key> = <value> +example: +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (Windows NT 6.1; rv:61.0) Gecko/20100101 Firefox/61.0 + */ + $dataLineStr = \substr($lineStr, \strlen('#EXTVLCOPT:')); + $dataLineStr = \trim($dataLineStr); + + [$key, $value] = \explode('=', $dataLineStr, 2); + + $this->setKey($key); + $this->setValue($value); + } +} diff --git a/src/core/Parsing/M3uParser/src/Tag/Playlist.php b/src/core/Parsing/M3uParser/src/Tag/Playlist.php new file mode 100644 index 00000000..88d12610 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/Tag/Playlist.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tag; + +/** + * Playlist display title. + * + * @see https://github.com/Gemorroj/M3uParser/issues/29 + */ +class Playlist implements ExtTagInterface { + private string $value; + + /** + * #PLAYLIST:My favorite music. + */ + public function __construct(?string $lineStr = null) { + if (null !== $lineStr) { + $this->make($lineStr); + } + } + + public function __toString(): string { + return '#PLAYLIST:' . $this->getValue(); + } + + public function setValue(string $value): self { + $this->value = $value; + + return $this; + } + + public function getValue(): string { + return $this->value; + } + + public static function isMatch(string $lineStr): bool { + return 0 === \stripos($lineStr, '#PLAYLIST:'); + } + + protected function make(string $lineStr): void { + /* +PLAYLIST format: +#PLAYLIST:<value> +example: +#PLAYLIST:My favorite music + */ + $dataLineStr = \substr($lineStr, \strlen('#PLAYLIST:')); + $dataLineStr = \trim($dataLineStr); + + $this->setValue($dataLineStr); + } +} diff --git a/src/core/Parsing/M3uParser/src/TagAttributesTrait.php b/src/core/Parsing/M3uParser/src/TagAttributesTrait.php new file mode 100644 index 00000000..af0d0689 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/TagAttributesTrait.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser; + +/** + * @see http://l189-238-14.cn.ru/api-doc/m3u-extending.html + */ +trait TagAttributesTrait { + /** + * @var array<string, string> + */ + private array $attributes = []; + + /** + * example string: tvg-ID="" tvg-name="MEDI 1 SAT" tvg-logo="" group-title="ARABIC". + */ + public function initAttributes(string $attrString): void { + $this->parseAttributes($attrString); + } + + /** + * @return array<string, string> + */ + public function getAttributes(): array { + return $this->attributes; + } + + public function getAttribute(string $name): ?string { + return $this->attributes[$name] ?? null; + } + + public function hasAttribute(string $name): bool { + return isset($this->attributes[$name]); + } + + /** + * @param array<string, string> $attributes + */ + public function setAttributes(array $attributes): self { + $this->attributes = $attributes; + + return $this; + } + + public function setAttribute(string $name, string $value): self { + $this->attributes[$name] = $value; + + return $this; + } + + protected function getAttributesString(): string { + $out = ''; + + foreach ($this->getAttributes() as $name => $value) { + $escapedValue = \addcslashes($value, '"'); + $out .= "$name=\"$escapedValue\" "; + } + + return \rtrim($out); + } + + private function parseAttributes(string $attrString): void { + \preg_match_all('/([^=" ]+)=("(?:\\\"|[^"])*"|(?:\\\"|[^=" ])+)/', $attrString, $matches, \PREG_SET_ORDER); + + foreach ($matches as $matchPair) { + $key = $matchPair[1]; + $value = \stripslashes(\trim($matchPair[2], '"')); + + $this->setAttribute($key, $value); + } + } +} diff --git a/src/core/Parsing/M3uParser/src/TagsManagerTrait.php b/src/core/Parsing/M3uParser/src/TagsManagerTrait.php new file mode 100644 index 00000000..09b7a7e9 --- /dev/null +++ b/src/core/Parsing/M3uParser/src/TagsManagerTrait.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser; + +use M3uParser\Tag\ExtAlb; +use M3uParser\Tag\ExtAlbumArtUrl; +use M3uParser\Tag\ExtArt; +use M3uParser\Tag\ExtGenre; +use M3uParser\Tag\ExtGrp; +use M3uParser\Tag\ExtImg; +use M3uParser\Tag\ExtInf; +use M3uParser\Tag\ExtLogo; +use M3uParser\Tag\ExtTagInterface; +use M3uParser\Tag\ExtTitle; +use M3uParser\Tag\ExtTv; +use M3uParser\Tag\ExtVlcOpt; +use M3uParser\Tag\Playlist; + +trait TagsManagerTrait { + /** + * @var class-string<ExtTagInterface>[] + */ + private array $tags = []; + + /** + * Add tag. + * + * @param class-string<ExtTagInterface> $tag class name. Must implements the ExtTagInterface interface + */ + public function addTag(string $tag): self { + $implements = @\class_implements($tag); + if (false === $implements) { + throw new Exception(\sprintf('Unknown class %s', $tag)); + } + if (!\in_array(ExtTagInterface::class, $implements, true)) { + throw new Exception(\sprintf('The class %s must implements the %s interface', $tag, ExtTagInterface::class)); + } + + $this->tags[] = $tag; + + return $this; + } + + /** + * Add default tags (EXTINF, EXTTV, EXTLOGO, EXTVLCOPT, EXTGRP, PLAYLIST, EXTTITLE, EXTALBUMARTURL, EXTGENRE, EXTART, EXTALB, EXTIMG). + */ + public function addDefaultTags(): self { + $this->addTag(ExtInf::class); + $this->addTag(ExtTv::class); + $this->addTag(ExtLogo::class); + $this->addTag(ExtVlcOpt::class); + $this->addTag(ExtGrp::class); + $this->addTag(Playlist::class); + $this->addTag(ExtTitle::class); + $this->addTag(ExtAlbumArtUrl::class); + $this->addTag(ExtGenre::class); + $this->addTag(ExtArt::class); + $this->addTag(ExtAlb::class); + $this->addTag(ExtImg::class); + + return $this; + } + + /** + * Remove all previously defined tags. + */ + public function clearTags(): self { + $this->tags = []; + + return $this; + } + + /** + * Get all active tags. + * + * @return class-string<ExtTagInterface>[] + */ + protected function getTags(): array { + return $this->tags; + } +} diff --git a/src/domain/Stream/M3UEntry.php b/src/domain/Stream/M3UEntry.php deleted file mode 100644 index 58e321ba..00000000 --- a/src/domain/Stream/M3UEntry.php +++ /dev/null @@ -1,579 +0,0 @@ -<?php - -/** - * M3uEntry — m3u entry - * - * @package XC_VM_Includes_Libs - * @author Divarion_D <https://github.com/Divarion-D> - * @copyright 2025-2026 Vateron Media - * @link https://github.com/Vateron-Media/XC_VM - * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html - */ - -trait TagAttributesTrait { - /** - * @var array - */ - private $attributes = array(); - - /** - * example string: tvg-ID="" tvg-name="MEDI 1 SAT" tvg-logo="" group-title="ARABIC". - */ - public function initAttributes(string $attrString): void { - $this->parseQuotedAttributes($attrString); - $this->parseNotQuotedAttributes($attrString); - } - - public function getAttributes(): array { - return $this->attributes; - } - - public function getAttribute(string $name): ?string { - return $this->attributes[$name] ?? null; - } - - public function hasAttribute(string $name): bool { - return isset($this->attributes[$name]); - } - - /** - * @return $this - */ - public function setAttributes(array $attributes): TagAttributesTrait { - $this->attributes = $attributes; - - return $this; - } - - /** - * @return $this - */ - public function setAttribute(string $name, string $value): TagAttributesTrait { - $this->attributes[$name] = $value; - - return $this; - } - - protected function getAttributesString(): string { - $out = ''; - - foreach ($this->getAttributes() as $name => $value) { - $out .= $name . '="' . $value . '" '; - } - - return rtrim($out); - } - - private function parseQuotedAttributes(string $attrString): void { - preg_match_all('/([a-zA-Z0-9\\-]+)="([^"]*)"/', $attrString, $matches, PREG_SET_ORDER); - - foreach ($matches as $match) { - $this->setAttribute($match[1], $match[2]); - } - } - - private function parseNotQuotedAttributes(string $attrString): void { - preg_match_all('/([a-zA-Z0-9\\-]+)=([^ "]+)/', $attrString, $matches, PREG_SET_ORDER); - - foreach ($matches as $match) { - $this->setAttribute($match[1], $match[2]); - } - } -} - -trait TagsManagerTrait { - /** - * @var array - */ - private $tags = array(); - - /** - * Add tag. - * - * @param string $tag class name must be implement ExtTagInterface interface - * - * @return $this - */ - public function addTag(string $tag): TagsManagerTrait { - if (!in_array('ExtTagInterface', class_implements($tag), true)) { - throw new Exception(sprintf('The class %s must be implement interface %s', $tag, 'ExtTagInterface')); - } - - $this->tags[] = $tag; - - return $this; - } - - /** - * Add default tags (EXTINF, EXTTV, EXTLOGO). - * - * @return $this - */ - public function addDefaultTags(): TagsManagerTrait { - $this->addTag('ExtInf'); - $this->addTag('ExtTv'); - $this->addTag('ExtLogo'); - - return $this; - } - - /** - * Remove all previously defined tags. - * - * @return $this - */ - public function clearTags(): TagsManagerTrait { - $this->tags = array(); - - return $this; - } - - /** - * Get all active tags. - * - * @return string[] - */ - protected function getTags(): array { - return $this->tags; - } -} - -class M3uEntry { - /** - * @var string - */ - protected $lineDelimiter = "\n"; - /** - * @var ExtTagInterface[] - */ - private $extTags = array(); - /** - * @var null|string - */ - private $path; - - public function __toString(): string { - $out = ''; - - foreach ($this->getExtTags() as $extTag) { - $out .= $extTag . $this->lineDelimiter; - } - - $out .= $this->getPath(); - - return rtrim($out); - } - - /** - * @return ExtTagInterface[] - */ - public function getExtTags(): array { - return $this->extTags; - } - - /** - * @return $this - */ - public function addExtTag(ExtTagInterface $extTag): M3uEntry { - $this->extTags[] = $extTag; - - return $this; - } - - /** - * Remove all previously defined tags. - * - * @return $this - */ - public function clearExtTags(): M3uEntry { - $this->extTags = array(); - - return $this; - } - - public function getPath(): string { - return $this->path; - } - - /** - * @return $this - */ - public function setPath(string $path): M3uEntry { - $this->path = $path; - - return $this; - } -} - -interface ExtTagInterface { - public function __construct(?string $lineStr); - - public function __toString(): string; - - public static function isMatch(string $lineStr): bool; -} - -class M3uParser { - use TagsManagerTrait; - - /** - * Parse m3u file. - */ - public function parseFile(string $file): M3uData { - $str = @file_get_contents($file); - - if (false === $str) { - throw new Exception('Can\'t read file.'); - } - - return $this->parse($str); - } - - /** - * Parse m3u string. - */ - public function parse(string $str): M3uData { - $this->removeBom($str); - $data = $this->createM3uData(); - $lines = explode("\n", $str); - $l = count($lines); - - for ($i = 0; $i < $l; ++$i) { - $lineStr = trim($lines[$i]); - - if (('' === $lineStr) || $this->isComment($lineStr)) { - } elseif ($this->isExtM3u($lineStr)) { - $tmp = trim(substr($lineStr, 7)); - - if ($tmp) { - $data->initAttributes($tmp); - } - } else { - $data->append($this->parseLine($i, $lines)); - } - } - - return $data; - } - - protected function createM3uEntry(): M3uEntry { - return new M3uEntry(); - } - - protected function createM3uData(): M3uData { - return new M3uData(); - } - - /** - * Parse one line. - * - * @param string[] $linesStr - */ - protected function parseLine(int &$lineNumber, array $linesStr): M3uEntry { - $entry = $this->createM3uEntry(); - $l = count($linesStr); - - while ($lineNumber < $l) { - $nextLineStr = $linesStr[$lineNumber]; - $nextLineStr = trim($nextLineStr); - - if (('' === $nextLineStr) || $this->isComment($nextLineStr) || $this->isExtM3u($nextLineStr)) { - } else { - $matched = false; - - foreach ($this->getTags() as $availableTag) { - if ($availableTag::isMatch($nextLineStr)) { - $matched = true; - $entry->addExtTag(new $availableTag($nextLineStr)); - - break; - } - } - - if (!$matched) { - $entry->setPath($nextLineStr); - - break; - } - } - - ++$lineNumber; - } - - return $entry; - } - - protected function removeBom(string &$str): void { - if (0 === strpos($str, '')) { - $str = substr($str, 3); - } - } - - protected function isExtM3u(string $lineStr): bool { - return 0 === stripos($lineStr, '#EXTM3U'); - } - - protected function isComment(string $lineStr): bool { - $matched = false; - - foreach ($this->getTags() as $availableTag) { - if ($availableTag::isMatch($lineStr)) { - $matched = true; - - break; - } - } - - return !$matched && (0 === strpos($lineStr, '#')) && !$this->isExtM3u($lineStr); - } -} - -class M3uData extends ArrayIterator { - use TagAttributesTrait; - - public function __toString(): string { - $out = rtrim('#EXTM3U ' . $this->getAttributesString()) . "\n"; - - foreach ($this as $entry) { - $out .= $entry . "\n"; - } - - return rtrim($out); - } -} - -class ExtInf implements ExtTagInterface { - use TagAttributesTrait; - - /** - * @var string - */ - private $title; - /** - * @var int - */ - private $duration; - - /** - * #EXTINF:-1 tvg-name=Первый_HD tvg-logo="Первый канал" deinterlace=4 group-title="Эфирные каналы",Первый канал HD. - */ - public function __construct(?string $lineStr = null) { - if (null !== $lineStr) { - $this->make($lineStr); - } - } - - public function __toString(): string { - return '#EXTINF: ' . $this->getDuration() . ' ' . $this->getAttributesString() . ', ' . $this->getTitle(); - } - - /** - * @return $this - */ - public function setTitle(string $title): ExtInf { - $this->title = $title; - - return $this; - } - - public function getTitle(): string { - return $this->title; - } - - /** - * @return $this - */ - public function setDuration(int $duration): ExtInf { - $this->duration = $duration; - - return $this; - } - - public function getDuration(): int { - return $this->duration; - } - - public static function isMatch(string $lineStr): bool { - return 0 === stripos($lineStr, '#EXTINF:'); - } - - /** - * @see http://l189-238-14.cn.ru/api-doc/m3u-extending.html - */ - protected function make(string $lineStr): void { - $dataLineStr = substr($lineStr, strlen('#EXTINF:')); - $dataLineStr = trim($dataLineStr); - preg_match('/^(-?\\d+)\\s*(?:(?:[^=]+=["\'][^"\']*["\'])|(?:[^=]+=[^ ]*))*,(.*)$/', $dataLineStr, $matches); - - if (!isset($matches[1], $matches[2])) { - $commaPos = strpos($dataLineStr, ','); - - if ($commaPos !== false) { - $this->setDuration(intval($dataLineStr)); - $this->setTitle(trim(substr($dataLineStr, $commaPos + 1))); - } else { - $this->setDuration(0); - $this->setTitle(trim($dataLineStr)); - } - - return; - } - - $this->setDuration((int) $matches[1]); - $this->setTitle(trim($matches[2])); - $attributes = preg_replace('/^' . preg_quote($matches[1], '/') . '(.*)' . preg_quote($matches[2], '/') . '$/', '$1', $dataLineStr); - $splitAttributes = explode(' ', $attributes, 2); - - if (isset($splitAttributes[1]) && ($trimmedAttributes = trim($splitAttributes[1]))) { - $this->initAttributes($trimmedAttributes); - } - } -} - -class ExtLogo implements ExtTagInterface { - /** - * @var string - */ - private $logo; - - /** - * #EXTLOGO:http://cdn1.siol.tv/logo/93x78/slo2.png. - */ - public function __construct(?string $lineStr = null) { - if (null !== $lineStr) { - $this->makeData($lineStr); - } - } - - public function __toString(): string { - return '#EXTLOGO: ' . $this->getLogo(); - } - - public function getLogo(): string { - return $this->logo; - } - - /** - * @return $this - */ - public function setLogo(string $logo): ExtLogo { - $this->logo = $logo; - - return $this; - } - - public static function isMatch(string $lineStr): bool { - return 0 === stripos($lineStr, '#EXTLOGO:'); - } - - protected function makeData(string $lineStr): void { - $tmp = substr($lineStr, strlen('#EXTLOGO:')); - $logo = trim($tmp); - $this->setLogo($logo); - } -} - -class ExtTv implements ExtTagInterface { - /** - * @var string[] - */ - private $tags; - /** - * @var string - */ - private $language; - /** - * @var string - */ - private $xmlTvId; - /** - * @var null|string - */ - private $iconUrl; - - /** - * #EXTTV:nacionalni,hd;slovenski;SLO1;http://cdn1.siol.tv/logo/93x78/slo2.png. - */ - public function __construct(?string $lineStr = null) { - if (null !== $lineStr) { - $this->makeData($lineStr); - } - } - - public function __toString(): string { - return '#EXTTV: ' . implode(',', $this->getTags()) . ';' . $this->getLanguage() . ';' . $this->getXmlTvId() . ($this->getIconUrl() ? ';' . $this->getIconUrl() : ''); - } - - /** - * @return string[] - */ - public function getTags(): array { - return $this->tags; - } - - /** - * @param string[] $tags - * - * @return $this - */ - public function setTags(array $tags): ExtTv { - $this->tags = $tags; - - return $this; - } - - public function getLanguage(): string { - return $this->language; - } - - /** - * @return $this - */ - public function setLanguage(string $language): ExtTv { - $this->language = $language; - - return $this; - } - - public function getXmlTvId(): string { - return $this->xmlTvId; - } - - /** - * @return $this - */ - public function setXmlTvId(string $xmlTvId): ExtTv { - $this->xmlTvId = $xmlTvId; - - return $this; - } - - public function getIconUrl(): ?string { - return $this->iconUrl; - } - - /** - * @return $this - */ - public function setIconUrl(?string $iconUrl): ExtTv { - $this->iconUrl = $iconUrl; - - return $this; - } - - public static function isMatch(string $lineStr): bool { - return 0 === stripos($lineStr, '#EXTTV:'); - } - - protected function makeData(string $lineStr): void { - $tmp = substr($lineStr, strlen('#EXTTV:')); - $split = explode(';', $tmp, 4); - $this->setTags(array_map('trim', explode(',', $split[0]))); - $this->setLanguage(trim($split[1])); - $this->setXmlTvId(trim($split[2])); - - if (isset($split[3])) { - $this->setIconUrl(trim($split[3])); - } - } -} diff --git a/src/domain/Stream/StreamService.php b/src/domain/Stream/StreamService.php index f70bb305..9fe36ab1 100644 --- a/src/domain/Stream/StreamService.php +++ b/src/domain/Stream/StreamService.php @@ -160,7 +160,7 @@ class StreamService { $rURL = $rResult->getPath(); if ($rURL) { - $rImportArray = array('stream_source' => array($rURL), 'stream_icon' => ($rTag ? ($rTag->getAttribute('tvg-logo') ?: '') : ''), 'stream_display_name' => ($rTag ? ($rTag->getTitle() ?: basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)) : basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)), 'epg_id' => null, 'epg_lang' => null, 'channel_id' => null); + $rImportArray = array('stream_source' => array($rURL), 'stream_icon' => ($rTag ? ($rTag->getAttribute('tvg-logo') ?: ($rTag->getAttribute('logo') ?: '')) : ''), 'stream_display_name' => ($rTag ? ($rTag->getTitle() ?: basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)) : basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)), 'epg_id' => null, 'epg_lang' => null, 'channel_id' => null); if ($rTag && $rTag->getAttribute('tvg-id')) { $rEPG = ($rEPGMatch[$i] ?? null); @@ -760,8 +760,8 @@ class StreamService { } public static function parseM3U($rData, $rFile = true) { - require_once MAIN_HOME . 'domain/Stream/M3UEntry.php'; - $rParser = new M3uParser(); + require_once MAIN_HOME . 'core/Parsing/M3uParser/bootstrap.php'; + $rParser = new \M3uParser\M3uParser(); $rParser->addDefaultTags(); if ($rFile) { diff --git a/src/public/Views/admin/review.php b/src/public/Views/admin/review.php index 00273d7f..55caa471 100644 --- a/src/public/Views/admin/review.php +++ b/src/public/Views/admin/review.php @@ -124,7 +124,7 @@ } else { if (count($rImport) < 500) { if ($rType == 1) { - $rImport[] = array('url' => $rURL, 'logo' => ($rTag ? ($rTag->getAttribute('tvg-logo') ?: '') : ''), 'tvg_id' => ($rTag ? ($rTag->getAttribute('tvg-id') ?: '') : ''), 'title' => ($rTag ? ($rTag->getTitle() ?: basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)) : basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)), 'category' => ($rTag ? ($rTag->getAttribute('group-title') ?: '') : ''), 'exists' => $rExists); + $rImport[] = array('url' => $rURL, 'logo' => ($rTag ? ($rTag->getAttribute('tvg-logo') ?: ($rTag->getAttribute('logo') ?: '')) : ''), 'tvg_id' => ($rTag ? ($rTag->getAttribute('tvg-id') ?: '') : ''), 'title' => ($rTag ? ($rTag->getTitle() ?: basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)) : basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)), 'category' => ($rTag ? ($rTag->getAttribute('group-title') ?: '') : ''), 'exists' => $rExists); } else { $rImport[] = array('url' => $rURL, 'title' => ($rTag ? ($rTag->getTitle() ?: basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)) : basename(parse_url($rURL, PHP_URL_PATH) ?: $rURL)), 'category' => ($rTag ? ($rTag->getAttribute('group-title') ?: '') : ''), 'exists' => $rExists); } diff --git a/tests/.tmp/bin/fake_bin.sh b/tests/.tmp/bin/fake_bin.sh new file mode 100644 index 00000000..039e4d00 --- /dev/null +++ b/tests/.tmp/bin/fake_bin.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exit 0 diff --git a/tests/Unit/M3uParser/ExceptionTest.php b/tests/Unit/M3uParser/ExceptionTest.php new file mode 100644 index 00000000..424c173d --- /dev/null +++ b/tests/Unit/M3uParser/ExceptionTest.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\Exception; +use PHPUnit\Framework\TestCase; + +class ExceptionTest extends TestCase +{ + public function test(): void + { + self::assertInstanceOf('Exception', new Exception()); + } +} diff --git a/tests/Unit/M3uParser/ExtCustomTag.php b/tests/Unit/M3uParser/ExtCustomTag.php new file mode 100644 index 00000000..33ee4851 --- /dev/null +++ b/tests/Unit/M3uParser/ExtCustomTag.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\Tag\ExtTagInterface; + +class ExtCustomTag implements ExtTagInterface +{ + private string $data = ''; + + /** + * #EXTCUSTOMTAG:data. + */ + public function __construct(?string $lineStr = null) + { + if (null !== $lineStr) { + $this->makeData($lineStr); + } + } + + public function __toString(): string + { + return '#EXTCUSTOMTAG: '.$this->getData(); + } + + public function getData(): string + { + return $this->data; + } + + public function setData(string $data): self + { + $this->data = $data; + + return $this; + } + + public static function isMatch(string $lineStr): bool + { + return 0 === \stripos($lineStr, '#EXTCUSTOMTAG:'); + } + + protected function makeData(string $lineStr): void + { + /* +EXTCUSTOMTAG format: +#EXTCUSTOMTAG:data +example: +#EXTCUSTOMTAG:123 + */ + + $data = \substr($lineStr, \strlen('#EXTCUSTOMTAG:')); + + $this->setData(\trim($data)); + } +} diff --git a/tests/Unit/M3uParser/M3uDataTest.php b/tests/Unit/M3uParser/M3uDataTest.php new file mode 100644 index 00000000..006b6c9a --- /dev/null +++ b/tests/Unit/M3uParser/M3uDataTest.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\Tag\ExtInf; +use M3uParser\Tag\ExtLogo; +use M3uParser\Tag\ExtTv; +use PHPUnit\Framework\TestCase; + +class M3uDataTest extends TestCase +{ + public function testComplexDefaultEntryToString(): void + { + $expectedString = '#EXTM3U test-name="test-value"'."\n"; + $expectedString .= '#EXTINF:123 test-attr="test-attrname",extinf-title'."\n"; + $expectedString .= '#EXTTV:hd,sd;ru;xml-tv-id;https://example.org/icon.png'."\n"; + $expectedString .= '#EXTLOGO:https://example.org/logo.png'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtInf()) + ->setDuration(123) + ->setTitle('extinf-title') + ->setAttribute('test-attr', 'test-attrname') + ); + $entry->addExtTag( + (new ExtTv()) + ->setIconUrl('https://example.org/icon.png') + ->setLanguage('ru') + ->setXmlTvId('xml-tv-id') + ->setTags(['hd', 'sd']) + ); + $entry->addExtTag( + (new ExtLogo()) + ->setValue('https://example.org/logo.png') + ); + + $data = new M3uData(); + $data->setAttribute('test-name', 'test-value'); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } + + /** + * @see https://github.com/Gemorroj/M3uParser/pull/14 + */ + public function testComplexDefaultEntriesToString(): void + { + $expectedString = '#EXTM3U test-name="test-value"'."\n"; + $expectedString .= '#EXTINF:123 test-attr="test-attrname1",extinf-title1'."\n"; + $expectedString .= '#EXTTV:hd,sd;ru;xml-tv-id;https://example.org/icon.png'."\n"; + $expectedString .= '#EXTLOGO:https://example.org/logo.png'."\n"; + $expectedString .= 'test-path1'."\n"; + $expectedString .= '#EXTINF:123 test-attr="test-attrname2",extinf-title2'."\n"; + $expectedString .= '#EXTTV:hd,sd;ru;xml-tv-id;https://example.org/icon.png'."\n"; + $expectedString .= '#EXTLOGO:https://example.org/logo.png'."\n"; + $expectedString .= 'test-path2'; + + $entry1 = new M3uEntry(); + $entry1->setPath('test-path1'); + $entry1->addExtTag( + (new ExtInf()) + ->setDuration(123) + ->setTitle('extinf-title1') + ->setAttribute('test-attr', 'test-attrname1') + ); + $entry1->addExtTag( + (new ExtTv()) + ->setIconUrl('https://example.org/icon.png') + ->setLanguage('ru') + ->setXmlTvId('xml-tv-id') + ->setTags(['hd', 'sd']) + ); + $entry1->addExtTag( + (new ExtLogo()) + ->setValue('https://example.org/logo.png') + ); + + $entry2 = new M3uEntry(); + $entry2->setPath('test-path2'); + $entry2->addExtTag( + (new ExtInf()) + ->setDuration(123) + ->setTitle('extinf-title2') + ->setAttribute('test-attr', 'test-attrname2') + ); + $entry2->addExtTag( + (new ExtTv()) + ->setIconUrl('https://example.org/icon.png') + ->setLanguage('ru') + ->setXmlTvId('xml-tv-id') + ->setTags(['hd', 'sd']) + ); + $entry2->addExtTag( + (new ExtLogo()) + ->setValue('https://example.org/logo.png') + ); + + $data = new M3uData([$entry1, $entry2]); + $data->setAttribute('test-name', 'test-value'); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/M3uParserTest.php b/tests/Unit/M3uParser/M3uParserTest.php new file mode 100644 index 00000000..8c3f105a --- /dev/null +++ b/tests/Unit/M3uParser/M3uParserTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\Exception as M3uParserException; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class M3uParserTest extends TestCase +{ + public function testParseFileFail(): void + { + $this->expectException(M3uParserException::class); + + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $m3uParser->parseFile('fake_file'); + } + + public function testParseFileExtM3u(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/fixtures/extm3u.m3u'); + + self::assertEquals([ + 'url-tvg' => 'http://www.teleguide.info/download/new3/jtv.zip', + 'm3uautoload' => '1', + 'deinterlace' => '8', + 'cache' => '500', + ], $data->getAttributes()); + self::assertEquals('http://www.teleguide.info/download/new3/jtv.zip', $data->getAttribute('url-tvg')); + } + + public function testParseFileComment(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/fixtures/comment.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('http://nullwave.barricade.lan:8000/club', $entry->getPath()); + self::assertEmpty($entry->getExtTags()); + } + + public function testParseFileNoTags(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/fixtures/notags.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('http://scfire-ntc-aa07.stream.aol.com:80/stream/1048', $entry->getPath()); + self::assertEmpty($entry->getExtTags()); + } + + public function testParseFileCombinedExtTags(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/fixtures/combined.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@232.2.201.53:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(2, $extTags); + + self::assertContainsOnlyInstancesOf(ExtTagInterface::class, $extTags); + } + + public function testParseFileExtCustomTag(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addTag(ExtCustomTag::class); + $data = $m3uParser->parseFile(__DIR__.'/fixtures/customtag.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('http://nullwave.barricade.lan:8000/club', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtCustomTag $extCustomTag */ + $extCustomTag = $extTags[0]; + self::assertInstanceOf(ExtCustomTag::class, $extCustomTag); + + self::assertEquals('123', $extCustomTag->getData()); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtAlbTest.php b/tests/Unit/M3uParser/Tag/ExtAlbTest.php new file mode 100644 index 00000000..77fb7bb4 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtAlbTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtAlb; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtAlbTest extends TestCase +{ + public function testParseExtAlb(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extalb.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtAlb $extAlb */ + $extAlb = $extTags[0]; + self::assertInstanceOf(ExtAlb::class, $extAlb); + + self::assertEquals('some album', $extAlb->getValue()); + } + + public function testGenerateExtAlb(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTALB:some album'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtAlb()) + ->setValue('some album') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtAlbumArtUrlTest.php b/tests/Unit/M3uParser/Tag/ExtAlbumArtUrlTest.php new file mode 100644 index 00000000..f6c1610f --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtAlbumArtUrlTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtAlbumArtUrl; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtAlbumArtUrlTest extends TestCase +{ + public function testParseExtAlbumArtUrl(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extalbumarturl.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtAlbumArtUrl $extAlbumArtUrl */ + $extAlbumArtUrl = $extTags[0]; + self::assertInstanceOf(ExtAlbumArtUrl::class, $extAlbumArtUrl); + + self::assertEquals('https://store.example.com/download/A32X5yz-1.jpg', $extAlbumArtUrl->getValue()); + } + + public function testGenerateExtAlbumArtUrl(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTALBUMARTURL:https://store.example.com/download/A32X5yz-1.jpg'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtAlbumArtUrl()) + ->setValue('https://store.example.com/download/A32X5yz-1.jpg') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtArtTest.php b/tests/Unit/M3uParser/Tag/ExtArtTest.php new file mode 100644 index 00000000..faa07d22 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtArtTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtArt; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtArtTest extends TestCase +{ + public function testParseExtArt(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extart.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtArt $extArt */ + $extArt = $extTags[0]; + self::assertInstanceOf(ExtArt::class, $extArt); + + self::assertEquals('some artist', $extArt->getValue()); + } + + public function testGenerateExtArt(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTART:some artist'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtArt()) + ->setValue('some artist') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtGenreTest.php b/tests/Unit/M3uParser/Tag/ExtGenreTest.php new file mode 100644 index 00000000..bf4281da --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtGenreTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtGenre; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtGenreTest extends TestCase +{ + public function testParseExtGenre(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extgenre.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtGenre $extGenre */ + $extGenre = $extTags[0]; + self::assertInstanceOf(ExtGenre::class, $extGenre); + + self::assertEquals('Rock', $extGenre->getValue()); + } + + public function testGenerateExtGenre(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTGENRE:Rock'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtGenre()) + ->setValue('Rock') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtGrpTest.php b/tests/Unit/M3uParser/Tag/ExtGrpTest.php new file mode 100644 index 00000000..54aa2e32 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtGrpTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtGrp; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtGrpTest extends TestCase +{ + public function testParseExtGrp(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extgrp.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtGrp $extGrp */ + $extGrp = $extTags[0]; + self::assertInstanceOf(ExtGrp::class, $extGrp); + + self::assertEquals('Rock', $extGrp->getValue()); + } + + public function testGenerateExtGrp(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTGRP:Rock'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtGrp()) + ->setValue('Rock') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtImgTest.php b/tests/Unit/M3uParser/Tag/ExtImgTest.php new file mode 100644 index 00000000..60830cfe --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtImgTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtImg; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtImgTest extends TestCase +{ + public function testParseExtImg(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extimg.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('Alternative\everclear_SMFTA.mp3', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtImg $extImg */ + $extImg = $extTags[0]; + self::assertInstanceOf(ExtImg::class, $extImg); + + self::assertEquals('http://example.org/logo.png', $extImg->getValue()); + } + + public function testGenerateExtImg(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTIMG:http://example.org/logo.png'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtImg()) + ->setValue('http://example.org/logo.png') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtInfTest.php b/tests/Unit/M3uParser/Tag/ExtInfTest.php new file mode 100644 index 00000000..a66cf6a0 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtInfTest.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtInf; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtInfTest extends TestCase +{ + public function testParseIssue23(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/issue23.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + /** @var ExtInf $extInf */ + $extInf = $entry->getExtTags()[0]; + + self::assertSame(5.279, $extInf->getDuration()); + } + + public function testParseExtInf(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extinf.m3u'); + + self::assertCount(6, $data); + + self::assertContainsOnlyInstancesOf(M3uEntry::class, $data); + + // basic + /** @var M3uEntry $firstEntry */ + $firstEntry = $data[0]; + + self::assertEquals('Alternative\everclear_SMFTA.mp3', $firstEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $firstEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('Everclear - So Much For The Afterglow', $extInf->getTitle()); + self::assertEquals(233, $extInf->getDuration()); + + self::assertEquals([], $extInf->getAttributes()); + + // cyrillic + /** @var M3uEntry $secondEntry */ + $secondEntry = $data[1]; + + self::assertEquals('http://176.51.55.8:1234/udp/233.7.70.200:5000', $secondEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $secondEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('Первый канал HD', $extInf->getTitle()); + self::assertEquals(-1, $extInf->getDuration()); + + self::assertEquals([], $extInf->getAttributes()); + + // attributes + /** @var M3uEntry $thirdEntry */ + $thirdEntry = $data[2]; + + self::assertEquals('http://109.225.233.1:30000/udp/239.255.10.160:5500', $thirdEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $thirdEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('Первый канал HD', $extInf->getTitle()); + self::assertEquals(-1, $extInf->getDuration()); + + self::assertEquals([ + 'tvg-logo' => 'Первый канал', + 'group-title' => 'Эфирные каналы', + 'tvg-name' => 'Первый_HD', + 'deinterlace' => '4', + ], $extInf->getAttributes()); + + // Comma in attribute value or title + /** @var M3uEntry $fourthEntry */ + $fourthEntry = $data[3]; + + self::assertEquals('http://117.210.233.1:3000/tcp/2', $fourthEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $fourthEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('Test with, comma 1/2', $extInf->getTitle()); + self::assertEquals(-1, $extInf->getDuration()); + + self::assertEquals([ + 'tvg-logo' => 'https://pngimage.net/wp-content/uploads/2018/05/iptv-png.png', + 'group-title' => '===test group title 1/2', + 'tvg-name' => 'Test with, comma 1/2', + ], $extInf->getAttributes()); + + // Spaces + /** @var M3uEntry $fiveEntry */ + $fiveEntry = $data[4]; + + self::assertEquals('http://127.0.0.1:3000', $fiveEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $fiveEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('Test with space', $extInf->getTitle()); + self::assertEquals(-1, $extInf->getDuration()); + + self::assertEquals([ + 'tvg-name' => 'Test with space', + ], $extInf->getAttributes()); + + // empty + /** @var M3uEntry $sixEntry */ + $sixEntry = $data[5]; + + self::assertEquals('name.mp3', $sixEntry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $sixEntry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtInf $extInf */ + $extInf = $extTags[0]; + self::assertInstanceOf(ExtInf::class, $extInf); + + self::assertEquals('', $extInf->getTitle()); + self::assertEquals(-1, $extInf->getDuration()); + + self::assertEquals([ + 'tvg-name' => 'Test empty name', + ], $extInf->getAttributes()); + } + + public function testGenerateExtInf(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTINF:123 test-attr="test-attrname",extinf-title'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtInf()) + ->setDuration(123) + ->setTitle('extinf-title') + ->setAttribute('test-attr', 'test-attrname') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } + + public function testAttributeQuotes(): void + { + $testString = '#EXTINF:-1 tvg-id="" tvg-name="" tvg-logo="" tvg-chno="7529" channel-id="7529" group-title="S: (HULU) The Handmaid\'s Tale",The Handmaid\'s Tale S01 E01'; + + $extInf = new ExtInf($testString); + + self::assertEquals($testString, (string) $extInf); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtLogoTest.php b/tests/Unit/M3uParser/Tag/ExtLogoTest.php new file mode 100644 index 00000000..65ca2f26 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtLogoTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtLogo; +use M3uParser\Tag\ExtTagInterface; +use PHPUnit\Framework\TestCase; + +class ExtLogoTest extends TestCase +{ + public function testParseExtLogo(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extlogo.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('Alternative\everclear_SMFTA.mp3', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtLogo $extLogo */ + $extLogo = $extTags[0]; + self::assertInstanceOf(ExtLogo::class, $extLogo); + + self::assertEquals('http://example.org/logo.png', $extLogo->getValue()); + } + + public function testGenerateExtLogo(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTLOGO:http://example.org/logo.png'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtLogo()) + ->setValue('http://example.org/logo.png') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtTitleTest.php b/tests/Unit/M3uParser/Tag/ExtTitleTest.php new file mode 100644 index 00000000..a4cfa790 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtTitleTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtTagInterface; +use M3uParser\Tag\ExtTitle; +use PHPUnit\Framework\TestCase; + +class ExtTitleTest extends TestCase +{ + public function testParseExtTitle(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/exttitle.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtTitle $extTitle */ + $extTitle = $extTags[0]; + self::assertInstanceOf(ExtTitle::class, $extTitle); + + self::assertEquals('Rock music', $extTitle->getValue()); + } + + public function testGenerateExtTitle(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTTITLE:Rock music'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtTitle()) + ->setValue('Rock music') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtTvTest.php b/tests/Unit/M3uParser/Tag/ExtTvTest.php new file mode 100644 index 00000000..f96efc0d --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtTvTest.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtTagInterface; +use M3uParser\Tag\ExtTv; +use PHPUnit\Framework\TestCase; + +class ExtTvTest extends TestCase +{ + public function testParseExtTv(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/exttv.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@232.2.201.53:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtTv $extTv */ + $extTv = $extTags[0]; + self::assertInstanceOf(ExtTv::class, $extTv); + + self::assertEquals(['Slovenski', 'HD'], $extTv->getTags()); + self::assertEquals('slv', $extTv->getLanguage()); + self::assertEquals('SLO1HD', $extTv->getXmlTvId()); + self::assertNull($extTv->getIconUrl()); + } + + public function testGenerateExtTv(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTTV:hd,sd;ru;xml-tv-id;https://example.org/icon.png'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtTv()) + ->setIconUrl('https://example.org/icon.png') + ->setLanguage('ru') + ->setXmlTvId('xml-tv-id') + ->setTags(['hd', 'sd']) + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/ExtVlcOptTest.php b/tests/Unit/M3uParser/Tag/ExtVlcOptTest.php new file mode 100644 index 00000000..49b10d44 --- /dev/null +++ b/tests/Unit/M3uParser/Tag/ExtVlcOptTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtTagInterface; +use M3uParser\Tag\ExtVlcOpt; +use PHPUnit\Framework\TestCase; + +class ExtVlcOptTest extends TestCase +{ + public function testParseExtVlcOpt(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/extvlcopt.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var ExtVlcOpt $extVlcOpt */ + $extVlcOpt = $extTags[0]; + self::assertInstanceOf(ExtVlcOpt::class, $extVlcOpt); + + self::assertEquals('http-user-agent', $extVlcOpt->getKey()); + self::assertEquals('Lavf53.32.100', $extVlcOpt->getValue()); + } + + public function testGenerateExtVlcOpt(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#EXTVLCOPT:some-key=some-value'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new ExtVlcOpt()) + ->setKey('some-key') + ->setValue('some-value') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/Tag/PlaylistTest.php b/tests/Unit/M3uParser/Tag/PlaylistTest.php new file mode 100644 index 00000000..730af2be --- /dev/null +++ b/tests/Unit/M3uParser/Tag/PlaylistTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests\Tag; + +use M3uParser\M3uData; +use M3uParser\M3uEntry; +use M3uParser\M3uParser; +use M3uParser\Tag\ExtTagInterface; +use M3uParser\Tag\Playlist; +use PHPUnit\Framework\TestCase; + +class PlaylistTest extends TestCase +{ + public function testParsePlaylist(): void + { + $m3uParser = new M3uParser(); + $m3uParser->addDefaultTags(); + $data = $m3uParser->parseFile(__DIR__.'/../fixtures/playlist.m3u'); + + /** @var M3uEntry $entry */ + $entry = $data[0]; + + self::assertEquals('rtp://@127.0.0.1:5003', $entry->getPath()); + + /** @var ExtTagInterface[] $extTags */ + $extTags = $entry->getExtTags(); + self::assertCount(1, $extTags); + + /** @var Playlist $playlist */ + $playlist = $extTags[0]; + self::assertInstanceOf(Playlist::class, $playlist); + + self::assertEquals('My favorite playlist', $playlist->getValue()); + } + + public function testGeneratePlaylist(): void + { + $expectedString = '#EXTM3U'."\n"; + $expectedString .= '#PLAYLIST:My favorite playlist'."\n"; + $expectedString .= 'test-path'; + + $entry = new M3uEntry(); + $entry->setPath('test-path'); + $entry->addExtTag( + (new Playlist()) + ->setValue('My favorite playlist') + ); + + $data = new M3uData(); + $data->append($entry); + + self::assertEquals($expectedString, (string) $data); + } +} diff --git a/tests/Unit/M3uParser/TagAttributesTraitTest.php b/tests/Unit/M3uParser/TagAttributesTraitTest.php new file mode 100644 index 00000000..16a6049b --- /dev/null +++ b/tests/Unit/M3uParser/TagAttributesTraitTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\TagAttributesTrait; +use PHPUnit\Framework\TestCase; + +class TagAttributesTraitTest extends TestCase +{ + use TagAttributesTrait; + + public function testParseAttributes(): void + { + $this->initAttributes('tvg-ID="" tvg-name="MEDI 1 SAT" group-title="ARABIC" tvg-name-custom=Первый_HD ext-tag="some\"quoted"'); + + $result = $this->getAttributes(); + + self::assertIsArray($result); + self::assertEquals([ + 'tvg-ID' => '', + 'tvg-name' => 'MEDI 1 SAT', + 'group-title' => 'ARABIC', + 'tvg-name-custom' => 'Первый_HD', + 'ext-tag' => 'some"quoted', + ], $result); + + self::assertEquals('Первый_HD', $this->getAttribute('tvg-name-custom')); + self::assertNull($this->getAttribute('fake-attribute')); + } + + public function testMakeAttributes(): void + { + $this->setAttribute('tvg-ID', ''); + $this->setAttribute('ext-tag', 'some"quoted'); + $this->setAttribute('tvg-name-custom', 'Первый HD'); + + $result = $this->getAttributesString(); + + self::assertIsString($result); + self::assertSame('tvg-ID="" ext-tag="some\"quoted" tvg-name-custom="Первый HD"', $result); + } +} diff --git a/tests/Unit/M3uParser/TagsManagerTraitTest.php b/tests/Unit/M3uParser/TagsManagerTraitTest.php new file mode 100644 index 00000000..cb291cec --- /dev/null +++ b/tests/Unit/M3uParser/TagsManagerTraitTest.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace M3uParser\Tests; + +use M3uParser\Exception; +use M3uParser\Tag\ExtInf; +use M3uParser\TagsManagerTrait; +use PHPUnit\Framework\TestCase; + +class TagsManagerTraitTest extends TestCase +{ + use TagsManagerTrait; + + public function testTags(): void + { + $this->addTag(ExtInf::class); + + $result = $this->getTags(); + + self::assertIsArray($result); + self::assertEquals([ExtInf::class], $result); + } + + public function testClearTags(): void + { + $this->addTag(ExtInf::class); + $this->clearTags(); + + $result = $this->getTags(); + + self::assertIsArray($result); + self::assertEquals([], $result); + } + + public function testErrorTags(): void + { + $this->expectException(Exception::class); + $this->addTag(self::class); // @phpstan-ignore-line + } +} diff --git a/tests/Unit/M3uParser/fixtures/combined.m3u b/tests/Unit/M3uParser/fixtures/combined.m3u new file mode 100644 index 00000000..88e36dcd --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/combined.m3u @@ -0,0 +1,5 @@ +#EXTM3U + +#EXTINF:1,TV SLO 1 HD +#EXTTV: Slovenski,HD;slv;SLO1HD +rtp://@232.2.201.53:5003 diff --git a/tests/Unit/M3uParser/fixtures/comment.m3u b/tests/Unit/M3uParser/fixtures/comment.m3u new file mode 100644 index 00000000..941b4f0c --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/comment.m3u @@ -0,0 +1,4 @@ +#EXTM3U +#EXTFAKETAG:123 +# Club channel (NULLWAVE.RU) +http://nullwave.barricade.lan:8000/club diff --git a/tests/Unit/M3uParser/fixtures/customtag.m3u b/tests/Unit/M3uParser/fixtures/customtag.m3u new file mode 100644 index 00000000..d76a13e4 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/customtag.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTCUSTOMTAG:123 +http://nullwave.barricade.lan:8000/club diff --git a/tests/Unit/M3uParser/fixtures/extalb.m3u b/tests/Unit/M3uParser/fixtures/extalb.m3u new file mode 100644 index 00000000..dd062864 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extalb.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTALB:some album +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/extalbumarturl.m3u b/tests/Unit/M3uParser/fixtures/extalbumarturl.m3u new file mode 100644 index 00000000..abe62ad5 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extalbumarturl.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTALBUMARTURL:https://store.example.com/download/A32X5yz-1.jpg +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/extart.m3u b/tests/Unit/M3uParser/fixtures/extart.m3u new file mode 100644 index 00000000..a04d396c --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extart.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTART:some artist +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/extgenre.m3u b/tests/Unit/M3uParser/fixtures/extgenre.m3u new file mode 100644 index 00000000..ea309dd4 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extgenre.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTGENRE:Rock +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/extgrp.m3u b/tests/Unit/M3uParser/fixtures/extgrp.m3u new file mode 100644 index 00000000..0868fd44 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extgrp.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTGRP:Rock +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/extimg.m3u b/tests/Unit/M3uParser/fixtures/extimg.m3u new file mode 100644 index 00000000..cc49b257 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extimg.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTIMG: http://example.org/logo.png +Alternative\everclear_SMFTA.mp3 diff --git a/tests/Unit/M3uParser/fixtures/extinf.m3u b/tests/Unit/M3uParser/fixtures/extinf.m3u new file mode 100644 index 00000000..e8a0c517 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extinf.m3u @@ -0,0 +1,18 @@ +#EXTM3U +#EXTINF:233,Everclear - So Much For The Afterglow +Alternative\everclear_SMFTA.mp3 + +#EXTINF:-1,Первый канал HD +http://176.51.55.8:1234/udp/233.7.70.200:5000 + +#EXTINF:-1 tvg-name=Первый_HD tvg-logo="Первый канал" deinterlace=4 group-title="Эфирные каналы",Первый канал HD +http://109.225.233.1:30000/udp/239.255.10.160:5500 + +#EXTINF:-1 tvg-name="Test with, comma 1/2" tvg-logo="https://pngimage.net/wp-content/uploads/2018/05/iptv-png.png" group-title="===test group title 1/2",Test with, comma 1/2 +http://117.210.233.1:3000/tcp/2 + +#EXTINF: -1 tvg-name="Test with space" , Test with space +http://127.0.0.1:3000 + +#EXTINF:-1 tvg-name="Test empty name", +name.mp3 diff --git a/tests/Unit/M3uParser/fixtures/extlogo.m3u b/tests/Unit/M3uParser/fixtures/extlogo.m3u new file mode 100644 index 00000000..7670db4c --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extlogo.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTLOGO: http://example.org/logo.png +Alternative\everclear_SMFTA.mp3 diff --git a/tests/Unit/M3uParser/fixtures/extm3u.m3u b/tests/Unit/M3uParser/fixtures/extm3u.m3u new file mode 100644 index 00000000..cecc3e3a --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extm3u.m3u @@ -0,0 +1,3 @@ +#EXTM3U url-tvg="http://www.teleguide.info/download/new3/jtv.zip" m3uautoload=1 deinterlace=8 cache=500 + +test diff --git a/tests/Unit/M3uParser/fixtures/exttitle.m3u b/tests/Unit/M3uParser/fixtures/exttitle.m3u new file mode 100644 index 00000000..ee39d1a9 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/exttitle.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTTITLE:Rock music +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/exttv.m3u b/tests/Unit/M3uParser/fixtures/exttv.m3u new file mode 100644 index 00000000..35dc7514 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/exttv.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTTV: Slovenski,HD;slv;SLO1HD +rtp://@232.2.201.53:5003 diff --git a/tests/Unit/M3uParser/fixtures/extvlcopt.m3u b/tests/Unit/M3uParser/fixtures/extvlcopt.m3u new file mode 100644 index 00000000..7e401b9b --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/extvlcopt.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTVLCOPT:http-user-agent=Lavf53.32.100 +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/M3uParser/fixtures/issue23.m3u b/tests/Unit/M3uParser/fixtures/issue23.m3u new file mode 100644 index 00000000..446f3444 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/issue23.m3u @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:35620 +#EXT-X-PROGRAM-DATE-TIME:2020-12-05T08:37:11Z +#EXTINF:5.279, +2020/12/05/08/37/11-05280.ts diff --git a/tests/Unit/M3uParser/fixtures/notags.m3u b/tests/Unit/M3uParser/fixtures/notags.m3u new file mode 100644 index 00000000..f00a9fb8 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/notags.m3u @@ -0,0 +1 @@ +http://scfire-ntc-aa07.stream.aol.com:80/stream/1048 diff --git a/tests/Unit/M3uParser/fixtures/playlist.m3u b/tests/Unit/M3uParser/fixtures/playlist.m3u new file mode 100644 index 00000000..778e6d60 --- /dev/null +++ b/tests/Unit/M3uParser/fixtures/playlist.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#PLAYLIST:My favorite playlist +rtp://@127.0.0.1:5003 diff --git a/tests/Unit/StreamServiceParseM3UTest.php b/tests/Unit/StreamServiceParseM3UTest.php new file mode 100644 index 00000000..edfbb2ae --- /dev/null +++ b/tests/Unit/StreamServiceParseM3UTest.php @@ -0,0 +1,128 @@ +<?php + +use PHPUnit\Framework\TestCase; + +/** + * Integration tests for StreamService::parseM3U(). + * + * Validates the parsing behaviour and the import array field extraction + * logic that the stream import pipeline relies on. + */ +final class StreamServiceParseM3UTest extends TestCase { + // ── helpers ──────────────────────────────────────────────────────────── + + /** Shortcut: parse inline M3U string via StreamService. */ + private function parse(string $m3u): \M3uParser\M3uData { + return StreamService::parseM3U($m3u, false); + } + + /** Build import array from a single parsed entry (mirrors the logic in process()). */ + private function buildImportArray(\M3uParser\M3uData $data): ?array { + foreach ($data as $result) { + $tags = $result->getExtTags(); + $tag = $tags[0] ?? null; + $url = $result->getPath(); + + if (!$url) { + return null; + } + + return [ + 'stream_source' => [$url], + 'stream_icon' => $tag ? ($tag->getAttribute('tvg-logo') ?: ($tag->getAttribute('logo') ?: '')) : '', + 'stream_display_name' => $tag + ? ($tag->getTitle() ?: basename(parse_url($url, PHP_URL_PATH) ?: $url)) + : basename(parse_url($url, PHP_URL_PATH) ?: $url), + ]; + } + + return null; + } + + // ── parseM3U tests ───────────────────────────────────────────────────── + + public function testParsesBasicExtInfEntry(): void { + $m3u = "#EXTM3U\n#EXTINF:-1 tvg-id=\"ch1\",Channel One\nhttp://example.com/stream.m3u8\n"; + + $data = $this->parse($m3u); + + $this->assertCount(1, $data); + } + + public function testParsesUrlCorrectly(): void { + $m3u = "#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/live.m3u8\n"; + + $data = $this->parse($m3u); + $entry = iterator_to_array($data)[0]; + + $this->assertSame('http://example.com/live.m3u8', $entry->getPath()); + } + + public function testEmptyM3UReturnsEmptyResult(): void { + $data = $this->parse("#EXTM3U\n"); + + $this->assertCount(0, $data); + } + + public function testParsesMultipleEntries(): void { + $m3u = "#EXTM3U\n" + . "#EXTINF:-1,Ch1\nhttp://example.com/1.m3u8\n" + . "#EXTINF:-1,Ch2\nhttp://example.com/2.m3u8\n" + . "#EXTINF:-1,Ch3\nhttp://example.com/3.m3u8\n"; + + $data = $this->parse($m3u); + + $this->assertCount(3, $data); + } + + // ── import array field extraction ────────────────────────────────────── + + public function testImportArrayContainsTvgLogo(): void { + $m3u = "#EXTM3U\n#EXTINF:-1 tvg-logo=\"http://example.com/logo.png\",Channel\nhttp://example.com/stream.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('http://example.com/logo.png', $arr['stream_icon']); + } + + public function testImportArrayFallsBackToLogoAttribute(): void { + $m3u = "#EXTM3U\n#EXTINF:-1 logo=\"http://example.com/fallback.png\",Channel\nhttp://example.com/stream.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('http://example.com/fallback.png', $arr['stream_icon']); + } + + public function testImportArrayIconEmptyWhenNoLogoAttributes(): void { + $m3u = "#EXTM3U\n#EXTINF:-1,Channel\nhttp://example.com/stream.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('', $arr['stream_icon']); + } + + public function testImportArrayUsesExtInfTitle(): void { + $m3u = "#EXTM3U\n#EXTINF:-1,My Channel Name\nhttp://example.com/stream.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('My Channel Name', $arr['stream_display_name']); + } + + public function testImportArrayFallsBackToUrlBasenameWhenNoTitle(): void { + // No EXTINF — entry has no tags. + $m3u = "#EXTM3U\nhttp://example.com/channel-name.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('channel-name.m3u8', $arr['stream_display_name']); + } + + public function testImportArrayTvgLogoTakesPrecedenceOverLogo(): void { + $m3u = "#EXTM3U\n#EXTINF:-1 tvg-logo=\"http://example.com/tvg.png\" logo=\"http://example.com/logo.png\",Channel\nhttp://example.com/stream.m3u8\n"; + + $arr = $this->buildImportArray($this->parse($m3u)); + + $this->assertSame('http://example.com/tvg.png', $arr['stream_icon']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c418bf2f..abb89375 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -57,4 +57,17 @@ if (!isset($_FILES)) { $_FILES = array(); } +if (!function_exists('igbinary_serialize')) { + function igbinary_serialize($value) { + return serialize($value); + } +} +if (!function_exists('igbinary_unserialize')) { + function igbinary_unserialize($value) { + return unserialize($value); + } +} + require_once MAIN_HOME . 'autoload.php'; +require_once MAIN_HOME . 'core/Parsing/M3uParser/bootstrap.php'; +require_once __DIR__ . '/Unit/M3uParser/ExtCustomTag.php';