Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable Episode upload as array #30

Closed
wants to merge 9 commits into from
70 changes: 65 additions & 5 deletions README.md
Expand Up @@ -5,16 +5,76 @@ This app serves as synchronization endpoint for AntennaPod: https://github.com/A

# API
## subscription
* *subscription*: `/index.php/apps/gpoddersync/subscriptions`
* *subscription change* : `/index.php/apps/gpoddersync/subscription_change/create`
* **get subscription changes**: `GET /index.php/apps/gpoddersync/subscriptions`
* *(optional)* GET parameter `since` (UNIX time)
* **upload subscription changes** : `POST /index.php/apps/gpoddersync/subscription_change/create`
* returns nothing

The API replicates this: https://gpoddernet.readthedocs.io/en/latest/api/reference/subscriptions.html

## episode action
* *episode actions*: `/index.php/apps/gpoddersync/episode_action`
* *create episode actions*: `/index.php/apps/gpoddersync/episode_action/create`
* **get episode actions**: `GET /index.php/apps/gpoddersync/episode_action`
* *(optional)* GET parameter `since` (UNIX time)
* fields: *podcast*, *episode*, *guid*, *action*, *timestamp*, *position*, *started*, *total*
* **create episode actions**: `POST /index.php/apps/gpoddersync/episode_action/create`
* fields: *podcast*, *episode*, *guid*, *action*, *timestamp*, *position*, *started*, *total*
* *position*, *started* and *total* are optional, default value is -1
* returns JSON with current timestamp

The API replicates this: https://gpoddernet.readthedocs.io/en/latest/api/reference/events.html

we also process the property `uuid`.
we also process the property `uuid`

#### Example requests:
```json
GET /index.php/apps/gpoddersync/episode_action?since=1633240761

{
"actions": [
{
"podcast": "http://example.com/feed.rss",
"episode": "http://example.com/files/s01e20.mp3",
"guid": "s01e20-example-org",
"action": "PLAY",
"timestamp": "2009-12-12T09:00:00",
"started": 15,
"position": 120,
"total": 500
},
{
"podcast": "http://example.com/feed.rss",
"episode": "http://example.com/files/s01e20.mp3",
"guid": "s01e20-example-org",
"action": "DOWNLOAD",
"timestamp": "2009-12-12T09:00:00",
"started": -1,
"position": -1,
"total": -1
},
],
"timestamp": 12345
}
```
```json
POST /index.php/apps/gpoddersync/episode_action/create

[
{
"podcast": "http://example.com/feed.rss",
"episode": "http://example.com/files/s01e20.mp3",
"guid": "s01e20-example-org",
"action": "play",
"timestamp": "2009-12-12T09:00:00",
"started": 15,
"position": 120,
"total": 500
},
{
"podcast": "http://example.org/podcast.php",
"episode": "http://ftp.example.org/foo.ogg",
"guid": "foo-bar-123",
"action": "DOWNLOAD",
"timestamp": "2009-12-12T09:05:21",
}
]
```
23 changes: 20 additions & 3 deletions lib/Controller/EpisodeActionController.php
Expand Up @@ -22,6 +22,8 @@ class EpisodeActionController extends Controller {
private $userId;
private EpisodeActionSaver $episodeActionSaver;

protected $request;

public function __construct(
string $AppName,
IRequest $request,
Expand All @@ -33,17 +35,23 @@ public function __construct(
$this->episodeActionRepository = $episodeActionRepository;
$this->userId = $UserId;
$this->episodeActionSaver = $episodeActionSaver;
$this->request = $request;
}

/**
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return Response
* @return JSONResponse
*/
public function create($data) {
return $this->episodeActionSaver->saveEpisodeActions($data, $this->userId);
public function create(): JSONResponse {

$episodeActionsArray = $this->filterEpisodesFromRequestParams($this->request->getParams());
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is done the right way, because the array filtering could have been done right here as well. I decided to do it this way to make the code better understandable.
Filtering out non-numeric indexes is necessary, because in the request params there is always one element "_route":"gpoddersync.episode_action.create", that has to be filtered out.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we get the json body as first argument in the controller action? Why do you retrieve the data from class property?

Copy link
Collaborator Author

@JonOfUs JonOfUs Oct 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had {"data": [{"podcast": ... }, {"podcast": ... }]}, we only got the json body as first argument because it is stored in the field "data" (before, the EpisodeAction string was stored in this field).
With my changes we expect [{"podcast": ... }, {"podcast": ... }]. This is not only very close to the gpodder api, Bytehamster even changed the payload for EpisodeAction upload to the same as gpodder.net in AntennaPod.


$this->episodeActionSaver->saveEpisodeActions($episodeActionsArray, $this->userId);

return new JSONResponse(["timestamp" => time()]);
}

/**
Expand All @@ -67,4 +75,13 @@ public function list(int $since): JSONResponse {
"timestamp" => time()
]);
}

/**
* @param array $requestParams
*
* @return array $episodeActionsArray
*/
public function filterEpisodesFromRequestParams(array $data): array {
return array_filter($data, "is_numeric", ARRAY_FILTER_USE_KEY);
}
}
54 changes: 15 additions & 39 deletions lib/Core/EpisodeAction/EpisodeActionReader.php
Expand Up @@ -5,52 +5,28 @@

class EpisodeActionReader
{

const EPISODEACTION_IDENTIFIER = 'EpisodeAction{';

/**
* @param string $episodeActionsString
* @param $episodeActionsArray[]
* @return EpisodeAction[]
*/
public function fromString(string $episodeActionsString): array
public function fromArray(array $episodeActionsArray): array
{


$patterns = [
'/EpisodeAction{(podcast=\')(?<podcast>.*?)(\', episode=\')(?<episode>.*?)(\', guid=\')(?<guid>.*?)(\', action=)(?<action>.*?)(, timestamp=)(?<timestamp>.*?)(, started=)(?<started>.*?)(, position=)(?<position>.*?)(, total=)(?<total>.*?)}]*/',
'/EpisodeAction{(podcast=\')(?<podcast>.*?)(\', episode=\')(?<episode>.*?)(\', action=)(?<action>.*?)(, timestamp=)(?<timestamp>.*?)(, started=)(?<started>.*?)(, position=)(?<position>.*?)(, total=)(?<total>.*?)}]*/',
];

$episodeActions = [];

$episodeActionStrings = explode(self::EPISODEACTION_IDENTIFIER, $episodeActionsString);
array_shift($episodeActionStrings);

foreach ($episodeActionStrings as $episodeActionString) {
foreach ($patterns as $pattern) {
preg_match(
$pattern,
self::EPISODEACTION_IDENTIFIER . $episodeActionString,
$matches
);

if ($matches["action"] !== null) {
$episodeActions[] = new EpisodeAction(
$matches["podcast"],
$matches["episode"],
$matches["action"],
$matches["timestamp"],
(int)$matches["started"],
(int)$matches["position"],
(int)$matches["total"],
$matches["guid"] ?? null,
null,
);
break;
}
}

foreach($episodeActionsArray as $episodeAction) {
$episodeActions[] = new EpisodeAction(
$episodeAction["podcast"],
$episodeAction["episode"],
strtoupper($episodeAction["action"]),
$episodeAction["timestamp"],
$episodeAction["started"] ?? -1,
$episodeAction["position"] ?? -1,
$episodeAction["total"] ?? -1,
$episodeAction["guid"] ?? null,
null
);
}

return $episodeActions;
}
}
13 changes: 7 additions & 6 deletions lib/Core/EpisodeAction/EpisodeActionSaver.php
Expand Up @@ -17,6 +17,8 @@ class EpisodeActionSaver
private EpisodeActionWriter $episodeActionWriter;
private EpisodeActionReader $episodeActionReader;

const DATETIME_FORMAT = 'Y-m-d\TH:i:s';

public function __construct(
EpisodeActionRepository $episodeActionRepository,
EpisodeActionWriter $episodeActionWriter,
Expand All @@ -29,15 +31,15 @@ public function __construct(
}

/**
* @param string $data
* @param array $episodeActionsArray
*
* @return EpisodeActionEntity[]
*/
public function saveEpisodeActions(string $data, string $userId): array
public function saveEpisodeActions($episodeActionsArray, string $userId): array
{
$episodeActionEntities = [];
$episodeActions = $this->episodeActionReader->fromArray($episodeActionsArray);

$episodeActions = $this->episodeActionReader->fromString($data);
$episodeActionEntities = [];

foreach ($episodeActions as $episodeAction) {
$episodeActionEntity = $this->hydrateEpisodeActionEntity($episodeAction, $userId);
Expand All @@ -57,8 +59,7 @@ public function saveEpisodeActions(string $data, string $userId): array

private function convertTimestampToUnixEpoch(string $timestamp): string
{
return \DateTime::createFromFormat('D F d H:i:s T Y', $timestamp)
->setTimezone(new DateTimeZone('UTC'))
return \DateTime::createFromFormat('Y-m-d\TH:i:s', $timestamp)
->format("U");
}

Expand Down
4 changes: 2 additions & 2 deletions tests/Integration/EpisodeActionRepositoryTest.php
Expand Up @@ -31,8 +31,8 @@ public function testTimestampOutputIsUTCHumandReadable() : void
$guid = uniqid("test_gid://art19-episode-locator/V0/Ktd");

$savedEpisodeActionEntity = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', guid='{$guid}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
self::USER_ID_0
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "guid" => $guid, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

self::assertSame(1629676736, $savedEpisodeActionEntity->getTimestampEpoch());
Expand Down
Expand Up @@ -32,12 +32,12 @@ public function testUpdateWithoutGuidDoesNotNullGuid() : void
$guid = uniqid("test_gid://art19-episode-locator/V0/Ktd");

$savedEpisodeActionEntity = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', guid='{$guid}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "guid" => $guid, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

$savedEpisodeActionEntityWithoutGuidFromOldDevice = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

Expand Down
10 changes: 5 additions & 5 deletions tests/Integration/EpisodeActionSaverGuidMigrationTest.php
Expand Up @@ -32,12 +32,12 @@ public function testCreateEpisodeActionWithoutGuidThenCreateAgainWithGuid() : vo
$guid = uniqid("test_gid://art19-episode-locator/V0/Ktd");

$savedEpisodeActionEntityWithoutGuid = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

$savedEpisodeActionEntityWithGuid = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', guid='{$guid}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "guid" => $guid, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

Expand All @@ -53,13 +53,13 @@ public function testCreateEpisodeActionWithGuidThenCreateAgainWithGuidButDiffere
$guid = uniqid("test_gid://art19-episode-locator/V0/Ktd");

$savedEpisodeActionEntity = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}', guid='{$guid}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl, "guid" => $guid, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

$savedEpisodeActionEntityWithDifferentEpisodeUrl = $episodeActionSaver->saveEpisodeActions(
"[EpisodeAction{podcast='https://rss.art19.com/dr-death-s3-miracle-man', episode='{$episodeUrl}_different', guid='{$guid}', action=PLAY, timestamp=Mon Aug 23 01:58:56 GMT+02:00 2021, started=47, position=54, total=2252}]",
self::USER_ID_0
[["podcast" => 'https://rss.art19.com/dr-death-s3-miracle-man', "episode" => $episodeUrl . "_different", "guid" => $guid, "action" => "PLAY", "timestamp" => "2021-08-22T23:58:56", "started" => 47, "position" => 54, "total" => 2252]],
self::USER_ID_0
)[0];

self::assertSame($savedEpisodeActionEntity->getId(), $savedEpisodeActionEntityWithDifferentEpisodeUrl->getId());
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/Migration/TimestampMigrationTest.php
Expand Up @@ -64,7 +64,7 @@ public function testTimestampConversionRepairStep()
$episodeActionEntity->setPosition(5);
$episodeActionEntity->setStarted(0);
$episodeActionEntity->setTotal(123);
$episodeActionEntity->setTimestamp("Sun Aug 22 23:58:56 GMT+00:00 2021");
$episodeActionEntity->setTimestamp("2021-08-22T23:58:56");
$episodeActionEntity->setUserId(self::ADMIN);
$guid = uniqid("self::TEST_GUID_1234");
$episodeActionEntity->setGuid($guid);
Expand Down