Большое обновление

- проект переписан на flight + twig, laravel-like хелперы
- docker-окружение
- новая страница с подробностями о плейлисте
- улучшен json о плейлисте
- нормальный роутинг
- нормальная статусная система
- попытка перекодировки при не utf-8 + предупреждение об этом
- дополнены FAQ + README
This commit is contained in:
2022-09-01 19:54:43 +08:00
parent 649ab85d79
commit c43439b9cc
37 changed files with 2566 additions and 860 deletions

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types = 1);
namespace App\Controllers;
abstract class Controller
{
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
namespace App\Controllers;
use App\Core\PlaylistProcessor;
use App\Core\RedirectedPlaylist;
use Exception;
use Flight;
class HomeController extends Controller
{
protected PlaylistProcessor $ini;
public function __construct()
{
$this->ini = new PlaylistProcessor();
}
/**
* @throws Exception
*/
public function index()
{
if (Flight::request()->query->count() > 0) {
$id = Flight::request()->query->keys()[0];
Flight::redirect(base_url("$id"));
die;
}
view('list', [
'updated_at' => $this->ini->updatedAt(),
'count' => $this->ini->playlists->count(),
'playlists' => $this->ini->playlists->where('redirect_id', null)->toArray(),
]);
}
/**
* @throws Exception
*/
public function faq()
{
view('faq');
}
/**
* @throws Exception
*/
public function details(string $id): void
{
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id . '/info'));
}
view('details', [
'id' => $id,
'playlist' => $playlist->toArray(),
'info' => $this->ini->parse($id),
]);
}
/**
* @throws Exception
*/
public function ajax(string $id): void
{
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id . '/getInfo'));
}
Flight::json([
'playlist' => $playlist->toArray(),
'info' => $this->ini->parse($id),
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types = 1);
namespace App\Controllers;
use App\Core\PlaylistProcessor;
use App\Core\RedirectedPlaylist;
use Exception;
use Flight;
class PlaylistController extends Controller
{
protected PlaylistProcessor $ini;
public function __construct()
{
$this->ini = new PlaylistProcessor();
}
/**
* @throws Exception
*/
public function download($id)
{
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id));
die;
}
Flight::redirect($playlist->pls);
}
/**
* @throws Exception
*/
public function details(string $id): void
{
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id . '/details'));
die;
}
view('details', [
...$playlist->toArray(),
...$this->ini->parse($id),
]);
}
/**
* @throws Exception
*/
public function json(string $id): void
{
$playlist = $this->ini->playlist($id);
if ($playlist instanceof RedirectedPlaylist) {
Flight::redirect(base_url($playlist->redirect_id . '/json'));
die;
}
Flight::json([
...$playlist->toArray(),
...$this->ini->parse($id),
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types = 1);
namespace App\Core;
use Illuminate\Contracts\Support\Arrayable;
abstract class BasicPlaylist implements Arrayable
{
public string $id;
public function url(): string
{
return sprintf('%s/%s', base_url(), $this->id);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types = 1);
namespace App\Core;
use App\Controllers\AjaxController;
use App\Controllers\HomeController;
use App\Controllers\PlaylistController;
use App\Extensions\TwigFunctions;
use Flight;
use Illuminate\Support\Arr;
use Symfony\Component\Dotenv\Dotenv;
use Twig\Environment;
use Twig\Extension\DebugExtension;
use Twig\Loader\FilesystemLoader;
final class Bootstrapper
{
public static function bootEnv(): void
{
(new Dotenv())->loadEnv(root_path() . '/.env');
}
public static function bootSettings(): void
{
$settings = Arr::dot(require_once config_path('app.php'));
Arr::map($settings, function ($value, $key) {
Flight::set("flight.$key", $value);
});
Flight::set('config', $settings);
}
public static function bootTwig(): void
{
$filesystemLoader = new FilesystemLoader(config('views.path'));
Flight::register(
'view',
Environment::class,
[$filesystemLoader, config('twig')],
function ($twig) {
/** @var Environment $twig */
Flight::set('twig', $twig);
$twig->addExtension(new TwigFunctions());
$twig->addExtension(new DebugExtension());
}
);
}
public static function bootRoutes(): void
{
Flight::route(
'GET /',
fn() => (new HomeController())->index()
);
Flight::route(
'GET /faq',
fn() => (new HomeController())->faq()
);
Flight::route(
'GET /@id:[a-zA-Z0-9_-]+',
fn($id) => (new PlaylistController())->download($id)
);
Flight::route(
'GET /?[a-zA-Z0-9_-]+',
fn($id) => (new PlaylistController())->download($id)
);
Flight::route(
'GET /@id:[a-zA-Z0-9_-]+/details',
fn($id) => (new PlaylistController())->details($id)
);
Flight::route(
'GET /@id:[a-zA-Z0-9_-]+/json',
fn($id) => (new PlaylistController())->json($id)
);
}
}

45
src/app/Core/Playlist.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);
namespace App\Core;
class Playlist extends BasicPlaylist
{
public ?string $name;
public ?string $desc;
public string $pls;
public ?string $src;
public string $url;
/**
* @throws \Exception
*/
public function __construct(public string $id, array $params)
{
empty($params['pls']) && throw new \Exception(
"Плейлист с ID=$id обязан иметь параметр pls или redirect"
);
$this->url = str_replace(['http://', 'https://'], '', base_url($id));
$this->name = $params['name'] ?? "Плейлист #$id";
$this->desc = $params['desc'] ?? null;
$this->pls = $params['pls'];
$this->src = $params['src'] ?? null;
}
public function toArray(): array
{
return [
'id' => $this->id,
'url' => $this->url,
'name' => $this->name,
'desc' => $this->desc,
'pls' => $this->pls,
'src' => $this->src,
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types = 1);
namespace App\Core;
use Illuminate\Support\Collection;
final class PlaylistProcessor
{
public Collection $playlists;
protected string $updated_at;
public function __construct()
{
$filepath = config_path('playlists.ini');
$this->updated_at = date('d.m.Y h:i', filemtime($filepath));
$this->playlists = collect(parse_ini_file($filepath, true))
->transform(function ($playlist, $id) {
return empty($playlist['redirect'])
? new Playlist((string)$id, $playlist)
: new RedirectedPlaylist((string)$id, $playlist['redirect']);
});
}
public function hasId(string $id): bool
{
return in_array($id, $this->playlists->keys()->toArray());
}
public function playlist(string $id): Playlist|RedirectedPlaylist
{
!$this->hasId($id) && throw new \InvalidArgumentException("Плейлист с ID=$id не найден");
return $this->playlists[$id];
}
public function check(string $id): bool
{
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $this->playlist($id)['pls']);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_TIMEOUT, 5);
curl_setopt($curl, CURLOPT_HEADER, 0);
curl_setopt($curl, CURLOPT_NOBODY, 1);
curl_exec($curl);
$code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
return $code < 400;
}
protected function fetch(string $id)
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $this->playlist($id)->pls,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HEADER => false,
CURLOPT_FAILONERROR => true,
]);
$content = curl_exec($curl);
$http_code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$err_code = curl_errno($curl);
$err_text = curl_error($curl);
curl_close($curl);
return [
'content' => $content,
'http_code' => $http_code,
'err_code' => $err_code,
'err_text' => $err_text,
];
}
protected function guessStatus(int $curl_err_code): string
{
return match ($curl_err_code) {
0 => 'online',
28 => 'timeout',
5, 6, 7, 22, 35 => 'offline',
default => 'error',
};
}
public function parse(string $id): array
{
$fetched = $this->fetch($id);
if ($fetched['err_code'] > 0) {
return [
'status' => $this->guessStatus($fetched['err_code']),
'error' => [
'code' => $fetched['err_code'],
'message' => $fetched['err_text'],
],
];
}
$result['status'] = $this->guessStatus($fetched['err_code']);
$result['encoding']['name'] = 'UTF-8';
$result['encoding']['alert'] = false;
if (($enc = mb_detect_encoding($fetched['content'], config('app.pls_encodings'))) !== 'UTF-8') {
$fetched['content'] = mb_convert_encoding($fetched['content'], 'UTF-8', $enc);
$result['encoding']['name'] = $enc;
$result['encoding']['alert'] = true;
}
$matches = [];
preg_match_all("/^#EXTINF:-?\d.*,\s*(.*)/m", $fetched['content'], $matches);
$result['channels'] = array_map('trim', $matches[1]);
$result['count'] = $fetched['http_code'] < 400 ? count($result['channels']) : 0;
return $result;
}
/**
* @return string
*/
public function updatedAt(): string
{
return $this->updated_at;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types = 1);
namespace App\Core;
class RedirectedPlaylist extends BasicPlaylist
{
/**
* @throws \Exception
*/
public function __construct(
public string $id,
public string $redirect_id,
) {
}
public function toArray(): array
{
return [
'id' => $this->id,
'redirect_id' => $this->redirect_id,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);
namespace App\Extensions;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class TwigFunctions extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('config', [$this, 'config']),
new TwigFunction('base_url', [$this, 'base_url']),
];
}
public function config(string $key, mixed $default = null): mixed
{
return config($key, $default);
}
public function base_url(string $path = ''): string
{
return base_url($path);
}
}