Initial commit
This commit is contained in:
39
src/API/LoggerFactory.php
Normal file
39
src/API/LoggerFactory.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Phlaym\PhotoprismApi\API;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\HandlerInterface;
|
||||
|
||||
/** Simple factory to create a logger without needing to set stream handlers every time */
|
||||
class LoggerFactory
|
||||
{
|
||||
private static $handlers = [];
|
||||
|
||||
/**
|
||||
* Add a new handler which is automatically added to the list of handlers
|
||||
* for all _future_ loggers
|
||||
*
|
||||
* @param HandlerInterface $handler The handler to add
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function addHandler(HandlerInterface $handler): void
|
||||
{
|
||||
self::$handlers[] = $handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Logger with the specified name
|
||||
*
|
||||
* @param string $name
|
||||
*
|
||||
* @return Logger
|
||||
*/
|
||||
public static function create(string $name): Logger
|
||||
{
|
||||
$l = new Logger($name);
|
||||
$l->setHandlers(self::$handlers);
|
||||
return $l;
|
||||
}
|
||||
}
|
448
src/API/PhotoPrism.php
Normal file
448
src/API/PhotoPrism.php
Normal file
@ -0,0 +1,448 @@
|
||||
<?php
|
||||
|
||||
namespace Phlaym\PhotoprismApi\API;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Phlaym\PhotoprismApi\Exceptions\NetworkException;
|
||||
use Phlaym\PhotoprismApi\Exceptions\AuthenticationException;
|
||||
use Phlaym\PhotoprismApi\Entities\Album;
|
||||
use Phlaym\PhotoprismApi\Entities\Photo;
|
||||
|
||||
/**
|
||||
* The main API class to interface with PhotoPrism
|
||||
*/
|
||||
class PhotoPrism
|
||||
{
|
||||
/** @var string $base_url Base URL of the PhotoPrism instance */
|
||||
public string $base_url = 'https://photos.phlaym.net';
|
||||
|
||||
/** @var string $api_url API URL of the PhotoPrism instance */
|
||||
public string $api_url = '';
|
||||
|
||||
/** @var string|null $session_id Session id of the currently logged in user */
|
||||
protected ?string $session_id = null;
|
||||
|
||||
/** @var string|null $download_token Token for downloading files */
|
||||
protected ?string $download_token = null;
|
||||
|
||||
/** @var string|null $preview_token Token for thumbnails files */
|
||||
protected ?string $preview_token = null;
|
||||
|
||||
/** @var array $config Configuration options */
|
||||
protected array $config;
|
||||
|
||||
/** @var LoggerInterface $logger Logger object */
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
/**
|
||||
* Creates a new Photoprism API object from the configuration
|
||||
*
|
||||
* @param array $config Configuration dictionary
|
||||
* @param string|null $log_path Path where the log files end up in.
|
||||
* Will be set to `$log_path = __DIR__.'/logs/log.log';` if empty.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
array $config,
|
||||
?string $log_path = null
|
||||
) {
|
||||
if (isset($config['host'])) {
|
||||
$this->base_url = $str = rtrim($config['host'], '/');
|
||||
}
|
||||
$this->api_url = $this->base_url . '/api/v1';
|
||||
$this->config = $config;
|
||||
if (empty($log_path)) {
|
||||
$log_path = __DIR__ . '/logs/log.log';
|
||||
}
|
||||
LoggerFactory::addHandler(new RotatingFileHandler($log_path, 5, Logger::DEBUG, true));
|
||||
$this->logger = LoggerFactory::create('Phlaym\PhotoprismApi');
|
||||
if (isset($_SESSION['pp_sessionid'])) {
|
||||
$this->session_id = $_SESSION['pp_sessionid'];
|
||||
$this->preview_token = $_SESSION['pp_preview_token'];
|
||||
$this->download_token = $_SESSION['pp_download_token'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse headers from a cURL HTTP response.
|
||||
* Returns an array with the keys `headers` and `content`.
|
||||
* The former is an array containing the headers (header name as key, value as value).
|
||||
* The latter is a string with the body of the response
|
||||
*
|
||||
* @param string $response The complete response, containing the headers and body
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function parseHeaders(string $response): array
|
||||
{
|
||||
$response = explode("\r\n\r\n", $response, 2);
|
||||
if (count($response) === 3) {
|
||||
$dropped = array_shift($response);
|
||||
$this->logger->warning('Dropping start of response:' . $dropped);
|
||||
}
|
||||
$headers = $response[0];
|
||||
if ($headers === 'HTTP/1.1 100 Continue') {
|
||||
$response = explode("\r\n\r\n", $response[1], 2);
|
||||
$headers = $response[0];
|
||||
}
|
||||
if (isset($response[1])) {
|
||||
$content = $response[1];
|
||||
} else {
|
||||
$content = '';
|
||||
}
|
||||
// this is not a good way to parse http headers
|
||||
// it will not (for example) take into account multiline headers
|
||||
// but what we're looking for is pretty basic, so we can ignore those shortcomings
|
||||
$response_headers = explode("\r\n", $headers);
|
||||
$header_arr = [];
|
||||
foreach ($response_headers as $header) {
|
||||
$header = explode(': ', $header, 2);
|
||||
if (count($header) < 2) {
|
||||
continue;
|
||||
}
|
||||
list($k,$v) = $header;
|
||||
$header_arr[$k] = $v;
|
||||
}
|
||||
return ['headers' => $header_arr, 'content' => $content];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a HTTP request using cURL
|
||||
* Returns the body of the response
|
||||
*
|
||||
* @param string $method -The HTTP method to use
|
||||
* @param string $path The HTTP request path without the API URL
|
||||
* @param array $data Request data to send
|
||||
* @param string $content_type Content-Type to use
|
||||
*
|
||||
* @throws NetworkException on failure
|
||||
*
|
||||
* @return string The response body
|
||||
*/
|
||||
private function makeRequest(
|
||||
string $method,
|
||||
string $path,
|
||||
array $data = [],
|
||||
string $content_type = 'application/json'
|
||||
): string {
|
||||
$url = $this->api_url . $path;
|
||||
$method = strtolower($method);
|
||||
$query_data = [];
|
||||
$headers = [];
|
||||
$result = null;
|
||||
try {
|
||||
if (is_array($data) && $method !== 'post-raw') {
|
||||
if ($content_type === 'application/json') {
|
||||
$query_data = json_encode($data);
|
||||
} elseif ($content_type === 'multipart/form-data') {
|
||||
$query_data = $data;
|
||||
} else {
|
||||
$query_data = http_build_query($data);
|
||||
}
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
if ($method === 'get' && !empty($query_data)) {
|
||||
$url .= '?' . $query_data;
|
||||
}
|
||||
if ($method !== 'get') {
|
||||
$headers[] = 'Content-Type: ' . $content_type;
|
||||
}
|
||||
|
||||
if (!empty($this->session_id)) {
|
||||
$headers[] = 'X-Session-Id: ' . $this->session_id;
|
||||
}
|
||||
$this->logger->info($method . ' request to ' . $url);
|
||||
$this->logger->debug('Headers ' . json_encode($headers));
|
||||
$this->logger->debug('postfields data ' . json_encode($data));
|
||||
$this->logger->debug('postfields ' . json_encode($query_data));
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, $method !== 'get');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
if ($method !== 'get' && !empty($query_data)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $query_data);
|
||||
}
|
||||
$output = curl_exec($ch);
|
||||
|
||||
// $request = curl_getinfo($ch, CURLINFO_HEADER_OUT);
|
||||
$http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if ($output === false) {
|
||||
throw new NetworkException("Error sending request to " . $url, 0);
|
||||
}
|
||||
if (empty($output) || $output === false) {
|
||||
$e = new NetworkException("No answer from" . $url, 0);
|
||||
$this->logger->error(
|
||||
"Error sending request. No answer from server",
|
||||
['Exception' => print_r($e, true)]
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
if ($http_status === 0) {
|
||||
throw new NetworkException('Unable to connect to API ' . $url);
|
||||
}
|
||||
if ($http_status >= 400) {
|
||||
throw new NetworkException('Invalid response ' . $url . ': ' . $http_status);
|
||||
}
|
||||
$result = $this->parseHeaders($output);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Error sending request", ['Exception' => print_r($e, true)]);
|
||||
throw new NetworkException("Error sending request to " . $url, 0, $e);
|
||||
}
|
||||
return $result['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in to PhotoPrism.
|
||||
* If already logged in nothing happens.
|
||||
* No check whether the session is still valid is performed
|
||||
|
||||
* @throws AuthenticationException on failure
|
||||
*
|
||||
* @param bool $force -Force re-login even if already logged in
|
||||
*/
|
||||
public function login(bool $force = false): void
|
||||
{
|
||||
if (!empty($this->session_id) && !$force) {
|
||||
$this->logger->info('Skipping login, already logged in');
|
||||
return;
|
||||
}
|
||||
$data = [
|
||||
'username' => $this->config['username'],
|
||||
'password' => $this->config['password'],
|
||||
];
|
||||
$res = $this->makeRequest('POST', '/session', $data);
|
||||
$this->logger->info('Login result: ' . $res);
|
||||
$response = json_decode($res, true);
|
||||
if (!empty($response['error'])) {
|
||||
throw new AuthenticationException($response['error']);
|
||||
}
|
||||
$this->session_id = $response['id'];
|
||||
$this->download_token = $response['config']['downloadToken'];
|
||||
$this->preview_token = $response['config']['previewToken'];
|
||||
$_SESSION['pp_sessionid'] = $this->session_id;
|
||||
$_SESSION['pp_preview_token'] = $this->preview_token;
|
||||
$_SESSION['pp_download_token'] = $this->download_token;
|
||||
$this->logger->debug('Session ID: ' . $this->session_id . ', preview token: ' . $this->preview_token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches albums from PhotoPrism
|
||||
|
||||
* @throws NetworkException on failure
|
||||
*
|
||||
* @param int $count -Maximum amount of albums to fetch
|
||||
* @param int $offset -Number of albums to skip
|
||||
* @return Album[]
|
||||
*/
|
||||
public function getAlbums(int $count = 1000, int $offset = 0): array
|
||||
{
|
||||
$data = [
|
||||
'count' => $count,
|
||||
'offset' => $offset,
|
||||
'type' => 'album'
|
||||
];
|
||||
$res = $this->makeRequest('GET', '/albums', $data, 'text/plain');
|
||||
$response = json_decode($res, true);
|
||||
if (!empty($response['error'])) {
|
||||
throw new NetworkException($response['error']);
|
||||
}
|
||||
$albums = array_map(
|
||||
function ($entry) {
|
||||
return new Album($entry);
|
||||
},
|
||||
$response
|
||||
);
|
||||
$this->logger->debug('Albums' . json_encode($albums));
|
||||
return $albums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches albums from PhotoPrism which can be viewed with the provided tokens
|
||||
|
||||
* @throws NetworkException on failure
|
||||
* @param string[] $tokens -A list of tokens by which the albums are filtered
|
||||
* @param int $count -Maximum amount of albums to fetch
|
||||
* @param int $offset -Number of albums to skip
|
||||
* @return Album[]
|
||||
*/
|
||||
public function getAlbumsByTokens(array $tokens, int $count = 1000, int $offset = 0): array
|
||||
{
|
||||
$this->logger->debug('getAlbumsByToken');
|
||||
$albums = $this->getAlbums($count, $offset);
|
||||
$visibleAlbums = [];
|
||||
foreach ($albums as $album) {
|
||||
$token = $this->getAlbumToken($album);
|
||||
$album->token = $token;
|
||||
if ($token != null && in_array($album->token, $tokens)) {
|
||||
$visibleAlbums[] = $album;
|
||||
} else {
|
||||
$this->logger->debug('Skipping album without access: ' . $album->title);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('getAlbumsByToken' . json_encode($visibleAlbums));
|
||||
return $visibleAlbums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an album from PhotoPrism which can be viewed with the provided token
|
||||
|
||||
* @throws NetworkException on failure
|
||||
* @param string $token -A token by which the albums are filtered
|
||||
* @return Album|null
|
||||
*/
|
||||
public function getAlbumByToken(string $token): ?Album
|
||||
{
|
||||
$this->logger->debug('getAlbumByToken');
|
||||
$albums = $this->getAlbumsByTokens([$token]);
|
||||
if (empty($albums)) {
|
||||
$this->logger->debug('Could not find album for token' . $token);
|
||||
return null;
|
||||
}
|
||||
$this->logger->debug('getAlbumByToken done');
|
||||
return $albums[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an album from PhotoPrism with the specified id. Needs an access token!
|
||||
|
||||
* @throws NetworkException on failure
|
||||
* @param string $id -id of the album
|
||||
* @param string $token -Access token
|
||||
* @return Album|null
|
||||
*/
|
||||
public function getAlbumById(string $id, string $token): ?Album
|
||||
{
|
||||
$this->logger->debug('getAlbumById', ['id' => $id]);
|
||||
$res = $this->makeRequest('GET', '/albums/' . $id, [], 'text/plain');
|
||||
$response = json_decode($res, true);
|
||||
if (!empty($response['error'])) {
|
||||
throw new NetworkException($response['error']);
|
||||
}
|
||||
$album = new Album($response);
|
||||
$album_token = $this->getAlbumToken($album);
|
||||
$album->token = $album_token;
|
||||
if ($album_token != null && $album_token == $token) {
|
||||
return $album;
|
||||
}
|
||||
$this->logger->warning('getAlbumById: Invalid token');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the secret token of an album
|
||||
|
||||
* @throws NetworkException on failure
|
||||
*
|
||||
* @param Album $album -The album which's toke should be fetched
|
||||
* @return string|null Album token or null if the album is private
|
||||
*/
|
||||
public function getAlbumToken($album): ?string
|
||||
{
|
||||
$uid = is_string($album) ? $album : $album->uid;
|
||||
$res = $this->makeRequest('GET', '/albums/' . $uid . '/links');
|
||||
/** @var array $response */
|
||||
$this->logger->debug('Token response: ' . $res);
|
||||
$response_array = json_decode($res, true);
|
||||
if (empty($response_array)) {
|
||||
$this->logger->warning('Empty response: ' . json_encode($res));
|
||||
return null;
|
||||
}
|
||||
$response = $response_array[0];
|
||||
if (!empty($response['error'])) {
|
||||
throw new NetworkException($response['error']);
|
||||
}
|
||||
|
||||
if (!isset($response['Token'])) {
|
||||
return null;
|
||||
}
|
||||
return $response['Token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload photos, optionally add them to a specific album
|
||||
|
||||
* @throws NetworkException on failure
|
||||
* @param string|null $album -The album UID to which the photos should be aded
|
||||
*/
|
||||
public function uploadPhotos(?string $album = null): void
|
||||
{
|
||||
$path = time();
|
||||
$url = '/upload/' . $path;
|
||||
$import_url = '/import' . $url;
|
||||
foreach (array_keys($_FILES['files']['tmp_name']) as $key) {
|
||||
$file_tmpname = $_FILES['files']['tmp_name'][$key];
|
||||
$this->logger->info('Uploading ' . $file_tmpname . ' to ' . $url);
|
||||
$filename = basename($_FILES['files']['name'][$key]);
|
||||
$cFile = curl_file_create($file_tmpname, $_FILES['files']['type'][$key], $filename);
|
||||
|
||||
$data = ['files' => $cFile];
|
||||
$res = $this->makeRequest('POST', $url, $data, 'multipart/form-data');
|
||||
$this->logger->info('Upload result: ' . $res);
|
||||
}
|
||||
|
||||
$this->logger->info('Importing files');
|
||||
/** @var string[] $albums */
|
||||
$albums = empty($album) ? [] : [$album];
|
||||
|
||||
$import_data = ["move" => true, "albums" => $albums];
|
||||
$res = $this->makeRequest('POST', $import_url, $import_data);
|
||||
$this->logger->info('Import result: ' . $res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the photos of an album
|
||||
|
||||
* @throws NetworkException on failure
|
||||
* @param Album $album - The album to which the photos should be loaded
|
||||
* @param int $count - How many photos should be loaded
|
||||
* @param int $offset - Offset for paging
|
||||
* @return Photo[] - Photos in the album
|
||||
*/
|
||||
public function getAlbumPhotos(Album $album, int $count = 60, int $offset = 0): array
|
||||
{
|
||||
$this->logger->debug('getAlbumPhotos');
|
||||
$data = [
|
||||
'album' => $album->uid,
|
||||
'count' => $count,
|
||||
'offset' => $offset
|
||||
];
|
||||
$res = $this->makeRequest('GET', '/photos', $data, 'text/plain');
|
||||
$response = json_decode($res, true);
|
||||
if (!empty($response['error'])) {
|
||||
throw new NetworkException($response['error']);
|
||||
}
|
||||
$this->logger->debug('getAlbumPhotos response');
|
||||
$photos = array_map(
|
||||
function ($entry) {
|
||||
$photo = new Photo($entry);
|
||||
$photo->thumbnail_token = $this->preview_token;
|
||||
return $photo;
|
||||
},
|
||||
$response
|
||||
);
|
||||
$this->logger->info('Got photos:' . json_encode($photos));
|
||||
$photos = array_filter(
|
||||
$photos,
|
||||
function ($obj) {
|
||||
static $idList = array();
|
||||
if (in_array($obj->uid, $idList)) {
|
||||
return false;
|
||||
}
|
||||
$idList[] = $obj->uid;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
$this->logger->info('unique photos:' . json_encode($photos));
|
||||
return $photos;
|
||||
}
|
||||
}
|
20160
src/API/logs/log-2023-02-18.log
Normal file
20160
src/API/logs/log-2023-02-18.log
Normal file
File diff suppressed because one or more lines are too long
13908
src/API/logs/log-2023-02-19.log
Normal file
13908
src/API/logs/log-2023-02-19.log
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user