Compare commits

...

10 commits

Author SHA1 Message Date
bc3a42b501 Import films 2023-10-08 23:30:41 -05:00
dfc11d7a7f Export films 2023-10-08 23:01:28 -05:00
1295b03c3a ♻️ Dependency injection 2023-06-22 18:59:19 -05:00
970db87cbc 🛠 Add trakt:debug command
I should really add a proper login command, and refresh
2023-06-19 21:54:57 -05:00
f058ca1fdf ⬆️ Upgrade Laravel to 10 2023-03-22 21:12:33 -05:00
fda7c13926 ♻️ DRY sync//history 2023-03-22 20:39:09 -05:00
a148fd32d7 Fetch season and episode from file tags 2022-12-06 11:29:17 -06:00
b5e70aec2e Watch episode directly 2022-10-29 12:33:22 -05:00
50c49cd0ef 🚧 Submit export shows to trakt
Still very messy
2022-09-15 07:54:28 -05:00
f33031ed8b 🐛 Use random string for empty title in filename 2022-09-13 17:01:36 -05:00
17 changed files with 3074 additions and 1201 deletions

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Commands;
use App\Services\Trakt;
use Illuminate\Support\Arr;
class TraktDebug extends Command
{
protected $signature = 'trakt:debug {endpoint : Trakt API endpoint to call} {method=GET : HTTP method to use} {--d|data=* : data}';
protected $description = 'Make arbitrary requests to Trakt API.' . PHP_EOL . '-d should be formatted with key=value or key:=value for non-strings';
public function handle(Trakt $trakt): int
{
$body = $this->getBody();
$method = $this->argument('method');
$url = $this->argument('endpoint');
$resp = $trakt->request()->asJson();
if ($body) {
$body = json_encode($body);
$resp->withBody($body);
}
$this->line("Request: $method $url $body");
$resp = $resp->send($method, $url);
$this->line('Response (' . $resp->status() . '):' . PHP_EOL . json_encode($resp->json(), JSON_PRETTY_PRINT));
return static::SUCCESS;
}
protected function getBody(): ?array
{
if ($this->argument('method') === 'GET') {
return null;
}
$ret = [];
foreach ($this->option('data') as $kv) {
[$k, $v] = explode('=', $kv) + [null, null];
if (empty($k) || empty($v)) {
continue;
}
if ($k[strlen($k) - 1] === ':') {
$k = substr($k, 0, strlen($k) - 1);
$v = json_decode($v, true);
if (json_last_error() !== JSON_ERROR_NONE) {
continue;
}
}
Arr::set($ret, $k, $v);
}
return $ret;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Commands;
use App\Data\WatchFile;
use App\Data\WatchData;
use App\Services\Trakt;
class TraktWatch extends Command
{
protected $signature = 'show:watch {file : File to watch}';
protected $description = 'Mark a show as watched right now on Trakt';
public function handle(Trakt $trakt): int
{
$file = WatchFile::from($this->argument('file'));
$data = WatchData::from(['rawData' => [$file->toArray()]]);
$resp = $trakt->syncHistory($data, $this->output);
return $resp->ok() ? static::SUCCESS : static::FAILURE;
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Commands;
use App\Data\WatchData;
use App\Services\Trakt;
class TraktWatchImport extends Command
{
protected $signature = 'show:watch:import {files* : JSON files to import}';
protected $description = 'Once online, sync watches from show:watch:export';
public function handle(Trakt $trakt): int
{
$watched = WatchData::from($this->arguments());
$resp = $trakt->syncHistory($watched, $this->output);
return $resp->ok() ? static::SUCCESS : static::FAILURE;
}
}

View file

@ -1,7 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Data\DataPipes; namespace App\Data\DataPipes;
use App\Data\Enums\Type;
use FFMpeg\FFMpeg;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use SimpleXMLElement; use SimpleXMLElement;
use Spatie\LaravelData\DataPipes\DataPipe; use Spatie\LaravelData\DataPipes\DataPipe;
@ -22,21 +26,61 @@ class GetSeasonEp implements DataPipe
$nfo = "{$pi['dirname']}/{$pi['filename']}.nfo"; $nfo = "{$pi['dirname']}/{$pi['filename']}.nfo";
if (file_exists($nfo)) { if (file_exists($nfo)) {
$properties['epNfo'] = $nfo; $properties['epNfo'] = $nfo;
$xml = simplexml_load_file($nfo);
$type = $xml->getName();
return $this->getEpFromNfo($properties, $nfo); if ($type === 'episodedetails') {
return $this->getEpFromNfo($properties, $xml);
} else if ($type === 'movie') {
return $this->getFilmFromNfo($properties, $xml);
}
}
return $this->getEpFromFfprobe($properties);
}
protected function getFilmFromNfo(Collection $properties, SimpleXMLElement $xml): Collection
{
$properties['type'] = Type::Movie;
$properties['showTitle'] = $this->getProp($xml, 'title');
$properties['movieTmdb'] = $this->getProp($xml, 'tmdbid');
$properties['movieImdb'] = $this->getProp($xml, 'imdbid');
if (!is_null($year = $this->getProp($xml, 'year'))) {
$properties['movieYear'] = (int) $year;
} }
return $properties; return $properties;
} }
protected function getEpFromNfo(Collection $properties, string $nfo): Collection protected function getEpFromNfo(Collection $properties, SimpleXMLElement $xml): Collection
{ {
$xml = simplexml_load_file($nfo); if (!is_null($seas = $this->getProp($xml, 'season'))) {
if ($xml->season) { $properties['season'] = (int) $seas;
$properties['season'] = (int) $xml->season;
} }
if ($xml->episode) { if (!is_null($ep = $this->getProp($xml, 'episode'))) {
$properties['episode'] = (int) $xml->episode; $properties['episode'] = (int) $ep;
}
return $properties;
}
protected function getProp(SimpleXMLElement $xml, string $prop): ?string
{
return $xml->$prop ? (string) $xml->$prop : null;
}
protected function getEpFromFfprobe(Collection $properties): Collection
{
$ffmpeg = FFMpeg::create();
$ffprobe = $ffmpeg->getFFProbe();
$tags = $ffprobe->format($properties->get('path'))->get('tags');
if ($season = $tags['season_number'] ?? $tags['SEASON_NUMBER'] ?? null) {
$properties['season'] = (int) $season;
}
if ($episode = $tags['episode_sort'] ?? $tags['EPISODE_SORT'] ?? null) {
$properties['episode'] = (int) $episode;
} }
return $properties; return $properties;

View file

@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace App\Data\DataPipes; namespace App\Data\DataPipes;
use App\Data\Enums\Type;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use SimpleXMLElement; use SimpleXMLElement;
use Spatie\LaravelData\DataPipes\DataPipe; use Spatie\LaravelData\DataPipes\DataPipe;
@ -28,6 +31,7 @@ class GetShowTmdb implements DataPipe
} }
$properties['showNfo'] = $nfo; $properties['showNfo'] = $nfo;
$properties['type'] = Type::Episode;
$xml = simplexml_load_file($nfo); $xml = simplexml_load_file($nfo);
$properties['showTmdb'] = $this->getProp($xml, 'tmdbid'); $properties['showTmdb'] = $this->getProp($xml, 'tmdbid');

View file

@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace App\Data\DataPipes; namespace App\Data\DataPipes;
use App\Data\Enums\Type;
use App\Data\FileData; use App\Data\FileData;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -18,12 +21,19 @@ class ParseWatchFile implements DataPipe
public function handle(mixed $payload, DataClass $class, Collection $properties): Collection public function handle(mixed $payload, DataClass $class, Collection $properties): Collection
{ {
$properties['watched'] = Carbon::now(); $properties['watched'] = Carbon::now();
$properties['output'] = sprintf( $properties['output'] = match ($properties['type']) {
Type::Movie => sprintf(
'%s-%d.json',
Str::slug($properties['showTitle'] ?? uniqid()),
$properties['movieYear'],
),
default => sprintf(
'%s-%dx%02d.json', '%s-%dx%02d.json',
Str::slug($properties['showTitle']), Str::slug($properties['showTitle'] ?? uniqid()),
$properties['season'], $properties['season'],
$properties['episode'] $properties['episode']
); ),
};
return $properties; return $properties;
} }

View file

@ -0,0 +1,26 @@
<?php
namespace App\Data\DataPipes;
use App\Data\FileData;
use Illuminate\Support\Collection;
use Spatie\LaravelData\DataPipes\DataPipe;
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
class ReadExportFile implements DataPipe
{
public function handle(mixed $payload, DataClass $class, Collection $properties): Collection
{
if (!$properties->has('path')) {
return $properties;
}
$properties['rawData'] = json_decode(file_get_contents($properties->get('path')), true) ?? [];
return $properties;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Data\DataPipes;
use App\Data\FileData;
use App\Data\WatchExport;
use Illuminate\Support\Collection;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\DataPipes\DataPipe;
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
class ReadExportFiles implements DataPipe
{
public function handle(mixed $payload, DataClass $class, Collection $properties): Collection
{
if (!$properties->has('files')) {
$properties['files'] = new DataCollection(WatchExport::class, []);
}
if (!$properties->get('files')->count()) {
return $properties;
}
$data = [];
foreach ($properties->get('files') as $file)
{
$data[] = $file->rawData;
}
$properties['rawData'] = $data;
return $properties;
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Data\DataPipes;
use App\Data\Enums\Type;
use App\Data\FileData;
use Illuminate\Support\Collection;
use Spatie\LaravelData\DataPipes\DataPipe;
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
class StructureData implements DataPipe
{
public function handle(mixed $payload, DataClass $class, Collection $properties): Collection
{
if (!$properties->has('rawData') || !count($properties->get('rawData'))) {
return $properties;
}
$data = [];
$films = [];
foreach ($properties->get('rawData') as $watch) {
switch ($watch['type']) {
case (Type::Episode->value):
$data = $this->getEp($data, $watch);
break;
case (Type::Movie->value):
$films[] = $this->getFilm($watch);
break;
}
}
$flat = [];
foreach ($data as $show) {
$show['seasons'] = array_values($show['seasons']);
$flat[] = $show;
}
$properties['structuredData'] = ['shows' => $flat, 'movies' => $films];
return $properties;
}
protected function getEp(array $data, array $watch): array
{
$showTmdb = $watch['showTmdb'];
$season = $watch['season'];
$episode = $watch['episode'];
$watched = $watch['watched'];
if (!array_key_exists($showTmdb, $data)) {
$data[$showTmdb] = [];
$data[$showTmdb]['ids'] = ['tmdb' => $showTmdb];
$data[$showTmdb]['seasons'] = [];
}
if (!array_key_exists($season, $data[$showTmdb]['seasons'])) {
$data[$showTmdb]['seasons'][$season] = [];
$data[$showTmdb]['seasons'][$season]['number'] = $season;
$data[$showTmdb]['seasons'][$season]['episodes'] = [];
}
$data[$showTmdb]['seasons'][$season]['episodes'][] = [
'number' => $episode,
'watched_at' => $watched,
];
return $data;
}
protected function getFilm(array $watch): array
{
$filmTmdb = $watch['movieTmdb'];
$filmYear = $watch['movieYear'];
$title = $watch['showTitle'];
$watched = $watch['watched'];
return [
'watched_at' => $watched,
'title' => $title,
'year' => $filmYear,
'ids' => [
'tmdb' => $filmTmdb,
],
];
}
}

11
app/Data/Enums/Type.php Normal file
View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Data\Enums;
enum Type: string {
case Unknown = "unknown";
case Episode = "episode";
case Movie = "movie";
}

24
app/Data/WatchData.php Normal file
View file

@ -0,0 +1,24 @@
<?php
namespace App\Data;
use App\Exceptions\Quit;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\DataPipeline;
class WatchData extends Data
{
#[DataCollectionOf(WatchExport::class)]
public DataCollection $files;
public array $rawData = [];
public array $structuredData = [];
public static function pipeline(): DataPipeline
{
return parent::pipeline()
->through(DataPipes\ReadExportFiles::class)
->through(DataPipes\StructureData::class)
;
}
}

17
app/Data/WatchExport.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Data;
use Spatie\LaravelData\DataPipeline;
class WatchExport extends FileData
{
public array $rawData = [];
public static function pipeline(): DataPipeline
{
return parent::pipeline()
->through(DataPipes\ReadExportFile::class)
;
}
}

View file

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Data; namespace App\Data;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -15,10 +17,14 @@ class WatchFile extends Data
public ?string $showTmdb = null; public ?string $showTmdb = null;
public ?string $showImdb = null; public ?string $showImdb = null;
public ?string $showTitle = null; public ?string $showTitle = null;
public ?string $movieTmdb = null;
public ?string $movieImdb = null;
public int $movieYear = 1900;
public ?string $epNfo = null; public ?string $epNfo = null;
public int $season = 0; public int $season = 0;
public int $episode = 0; public int $episode = 0;
public Carbon $watched; public Carbon $watched;
public Enums\Type $type = Enums\Type::Unknown;
public static function fromPath(string $path): static public static function fromPath(string $path): static
{ {

View file

@ -6,6 +6,7 @@ use App\Services\ProcessInput;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Queue\Factory as QueueFactoryContract; use Illuminate\Contracts\Queue\Factory as QueueFactoryContract;
use App\Queue\DatabaseConnector; use App\Queue\DatabaseConnector;
use App\Services\Trakt;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -28,5 +29,6 @@ class AppServiceProvider extends ServiceProvider
public function register() public function register()
{ {
$this->app->instance(ProcessInput::class, new ProcessInput()); $this->app->instance(ProcessInput::class, new ProcessInput());
$this->app->instance(Trakt::class, new Trakt());
} }
} }

39
app/Services/Trakt.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace App\Services;
use App\Data\WatchData;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Console\Output\OutputInterface;
class Trakt
{
protected $headers = [];
public function __construct()
{
$this->headers = [
'trakt-api-version' => 2,
'trakt-api-key' => env('TRAKT_APP_ID'),
'Authorization' => 'Bearer ' . env('TRAKT_OAUTH_TOKEN'),
];
}
public function request(array $headers = []): PendingRequest
{
return Http::withHeaders($this->headers + $headers)->baseUrl('https://api.trakt.tv');
}
public function syncHistory(WatchData $data, OutputInterface $output = null): Response
{
$output?->writeln(sprintf('Submitting %s to trakt with headers %s', json_encode($data->structuredData), json_encode($this->headers)));
$resp = $this->request()->post('/sync/history', $data->structuredData);
$output?->writeln('Response (' . $resp->status() . '):' . PHP_EOL . json_encode($resp->json(), JSON_PRETTY_PRINT));
return $resp;
}
}

View file

@ -27,12 +27,14 @@
} }
], ],
"require": { "require": {
"php": "^8.0", "php": "^8.1",
"danjones000/ffmpeg-mappable-media": "~0.2", "danjones000/ffmpeg-mappable-media": "~0.2",
"illuminate/database": "^9.0", "guzzlehttp/guzzle": "^7.5",
"illuminate/log": "^9.0", "illuminate/database": "^10.0",
"illuminate/queue": "^9.2", "illuminate/http": "^10.0",
"laravel-zero/framework": "^9.0", "illuminate/log": "^10.0",
"illuminate/queue": "^10.0",
"laravel-zero/framework": "^10.0",
"nunomaduro/termwind": "^1.3", "nunomaduro/termwind": "^1.3",
"php-ffmpeg/php-ffmpeg": "^1.0", "php-ffmpeg/php-ffmpeg": "^1.0",
"spatie/laravel-data": "^2.0" "spatie/laravel-data": "^2.0"

3827
composer.lock generated

File diff suppressed because it is too large Load diff