Move DuplicatePrevention to standalone class and build tests around it.
This commit is contained in:
parent
2c168156d8
commit
89bf2c1bc0
|
@ -29,7 +29,8 @@ class StationRequestRepository extends Repository
|
|||
LoggerInterface $logger,
|
||||
protected StationMediaRepository $mediaRepo,
|
||||
protected DeviceDetector $deviceDetector,
|
||||
protected BlocklistParser $blocklistParser
|
||||
protected BlocklistParser $blocklistParser,
|
||||
protected AutoDJ\DuplicatePrevention $duplicatePrevention,
|
||||
) {
|
||||
parent::__construct($em, $serializer, $environment, $logger);
|
||||
}
|
||||
|
@ -242,7 +243,7 @@ class StationRequestRepository extends Repository
|
|||
$eligibleTrack->title = $media->getTitle() ?? '';
|
||||
$eligibleTrack->artist = $media->getArtist() ?? '';
|
||||
|
||||
$isDuplicate = (null === AutoDJ\Queue::getDistinctTrack([$eligibleTrack], $recentTracks));
|
||||
$isDuplicate = (null === $this->duplicatePrevention->getDistinctTrack([$eligibleTrack], $recentTracks));
|
||||
|
||||
if ($isDuplicate) {
|
||||
throw new Exception(
|
||||
|
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Radio\AutoDJ;
|
||||
|
||||
use App\Entity;
|
||||
use Monolog\Logger;
|
||||
|
||||
class DuplicatePrevention
|
||||
{
|
||||
public const ARTIST_SEPARATORS = [
|
||||
', ',
|
||||
' feat ',
|
||||
' feat. ',
|
||||
' & ',
|
||||
' vs. ',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected Logger $logger
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
|
||||
* @param array $playedTracks
|
||||
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
|
||||
*/
|
||||
public function preventDuplicates(
|
||||
array $eligibleTracks = [],
|
||||
array $playedTracks = [],
|
||||
bool $allowDuplicates = false
|
||||
): ?Entity\Api\StationPlaylistQueue {
|
||||
if (empty($eligibleTracks)) {
|
||||
$this->logger->debug('Eligible song queue is empty!');
|
||||
return null;
|
||||
}
|
||||
|
||||
$latestSongIdsPlayed = [];
|
||||
|
||||
foreach ($playedTracks as $playedTrack) {
|
||||
$songId = $playedTrack['song_id'];
|
||||
|
||||
if (!isset($latestSongIdsPlayed[$songId])) {
|
||||
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Entity\Api\StationPlaylistQueue[] $notPlayedEligibleTracks */
|
||||
$notPlayedEligibleTracks = [];
|
||||
|
||||
foreach ($eligibleTracks as $mediaId => $track) {
|
||||
$songId = $track->song_id;
|
||||
if (isset($latestSongIdsPlayed[$songId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPlayedEligibleTracks[$mediaId] = $track;
|
||||
}
|
||||
|
||||
$validTrack = $this->getDistinctTrack($notPlayedEligibleTracks, $playedTracks);
|
||||
if (null !== $validTrack) {
|
||||
$this->logger->info(
|
||||
'Found track that avoids duplicate title and artist.',
|
||||
[
|
||||
'media_id' => $validTrack->media_id,
|
||||
'title' => $validTrack->title,
|
||||
'artist' => $validTrack->artist,
|
||||
]
|
||||
);
|
||||
|
||||
return $validTrack;
|
||||
}
|
||||
|
||||
// If we reach this point, there's no way to avoid a duplicate title and artist.
|
||||
if ($allowDuplicates) {
|
||||
/** @var Entity\Api\StationPlaylistQueue[] $mediaIdsByTimePlayed */
|
||||
$mediaIdsByTimePlayed = [];
|
||||
|
||||
// For each piece of eligible media, get its latest played timestamp.
|
||||
foreach ($eligibleTracks as $track) {
|
||||
$songId = $track->song_id;
|
||||
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
|
||||
$mediaIdsByTimePlayed[$trackKey] = $track;
|
||||
}
|
||||
|
||||
ksort($mediaIdsByTimePlayed);
|
||||
|
||||
$validTrack = array_shift($mediaIdsByTimePlayed);
|
||||
|
||||
// Pull the lowest value, which corresponds to the least recently played song.
|
||||
if (null !== $validTrack) {
|
||||
$this->logger->warning(
|
||||
'No way to avoid same title OR same artist; using least recently played song.',
|
||||
[
|
||||
'media_id' => $validTrack->media_id,
|
||||
'title' => $validTrack->title,
|
||||
'artist' => $validTrack->artist,
|
||||
]
|
||||
);
|
||||
|
||||
return $validTrack;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of eligible tracks, return the first ID that doesn't have a duplicate artist/
|
||||
* title with any of the previously played tracks.
|
||||
*
|
||||
* Both should be in the form of an array, i.e.:
|
||||
* [ 'id' => ['artist' => 'Foo', 'title' => 'Fighters'] ]
|
||||
*
|
||||
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
|
||||
* @param array $playedTracks
|
||||
*
|
||||
*/
|
||||
public function getDistinctTrack(
|
||||
array $eligibleTracks,
|
||||
array $playedTracks
|
||||
): ?Entity\Api\StationPlaylistQueue {
|
||||
$artists = [];
|
||||
$titles = [];
|
||||
foreach ($playedTracks as $playedTrack) {
|
||||
$title = $this->prepareStringForMatching($playedTrack['title']);
|
||||
$titles[$title] = $title;
|
||||
|
||||
foreach ($this->getArtistParts($playedTrack['artist']) as $artist) {
|
||||
$artists[$artist] = $artist;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($eligibleTracks as $track) {
|
||||
// Avoid all direct title matches.
|
||||
$title = $this->prepareStringForMatching($track->title);
|
||||
if (isset($titles[$title])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt to avoid an artist match, if possible.
|
||||
$compareArtists = [];
|
||||
foreach ($this->getArtistParts($track->artist) as $compareArtist) {
|
||||
$compareArtists[$compareArtist] = $compareArtist;
|
||||
}
|
||||
|
||||
if (empty(array_intersect_key($compareArtists, $artists))) {
|
||||
return $track;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getArtistParts(string $artists): array
|
||||
{
|
||||
$dividerString = chr(7);
|
||||
|
||||
$artistParts = explode(
|
||||
$dividerString,
|
||||
str_replace(self::ARTIST_SEPARATORS, $dividerString, trim($artists))
|
||||
);
|
||||
|
||||
return array_filter(
|
||||
array_map(
|
||||
[$this, 'prepareStringForMatching'],
|
||||
$artistParts
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function prepareStringForMatching(string $string): string
|
||||
{
|
||||
return mb_strtolower(trim($string));
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ class Queue implements EventSubscriberInterface
|
|||
protected EntityManagerInterface $em,
|
||||
protected Logger $logger,
|
||||
protected Scheduler $scheduler,
|
||||
protected DuplicatePrevention $duplicatePrevention,
|
||||
protected CacheInterface $cache,
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
protected Entity\Repository\StationPlaylistMediaRepository $spmRepo,
|
||||
|
@ -567,7 +568,7 @@ class Queue implements EventSubscriberInterface
|
|||
$mediaQueue = $this->spmRepo->getQueue($playlist);
|
||||
|
||||
if ($playlist->getAvoidDuplicates()) {
|
||||
return $this->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
|
||||
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
|
||||
}
|
||||
|
||||
return array_shift($mediaQueue);
|
||||
|
@ -603,98 +604,12 @@ class Queue implements EventSubscriberInterface
|
|||
$mediaQueue = $this->spmRepo->resetQueue($playlist);
|
||||
}
|
||||
|
||||
return $this->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
|
||||
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
|
||||
}
|
||||
|
||||
return array_shift($mediaQueue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
|
||||
* @param array $playedTracks
|
||||
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
|
||||
*/
|
||||
protected function preventDuplicates(
|
||||
array $eligibleTracks = [],
|
||||
array $playedTracks = [],
|
||||
bool $allowDuplicates = false
|
||||
): ?Entity\Api\StationPlaylistQueue {
|
||||
if (empty($eligibleTracks)) {
|
||||
$this->logger->debug('Eligible song queue is empty!');
|
||||
return null;
|
||||
}
|
||||
|
||||
$latestSongIdsPlayed = [];
|
||||
|
||||
foreach ($playedTracks as $playedTrack) {
|
||||
$songId = $playedTrack['song_id'];
|
||||
|
||||
if (!isset($latestSongIdsPlayed[$songId])) {
|
||||
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Entity\Api\StationPlaylistQueue[] $notPlayedEligibleTracks */
|
||||
$notPlayedEligibleTracks = [];
|
||||
|
||||
foreach ($eligibleTracks as $mediaId => $track) {
|
||||
$songId = $track->song_id;
|
||||
if (isset($latestSongIdsPlayed[$songId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPlayedEligibleTracks[$mediaId] = $track;
|
||||
}
|
||||
|
||||
$validTrack = self::getDistinctTrack($notPlayedEligibleTracks, $playedTracks);
|
||||
|
||||
if (null !== $validTrack) {
|
||||
$this->logger->info(
|
||||
'Found track that avoids duplicate title and artist.',
|
||||
[
|
||||
'media_id' => $validTrack->media_id,
|
||||
'title' => $validTrack->title,
|
||||
'artist' => $validTrack->artist,
|
||||
]
|
||||
);
|
||||
|
||||
return $validTrack;
|
||||
}
|
||||
|
||||
// If we reach this point, there's no way to avoid a duplicate title and artist.
|
||||
if ($allowDuplicates) {
|
||||
/** @var Entity\Api\StationPlaylistQueue[] $mediaIdsByTimePlayed */
|
||||
$mediaIdsByTimePlayed = [];
|
||||
|
||||
// For each piece of eligible media, get its latest played timestamp.
|
||||
foreach ($eligibleTracks as $track) {
|
||||
$songId = $track->song_id;
|
||||
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
|
||||
$mediaIdsByTimePlayed[$trackKey] = $track;
|
||||
}
|
||||
|
||||
ksort($mediaIdsByTimePlayed);
|
||||
|
||||
$validTrack = array_shift($mediaIdsByTimePlayed);
|
||||
|
||||
// Pull the lowest value, which corresponds to the least recently played song.
|
||||
if (null !== $validTrack) {
|
||||
$this->logger->warning(
|
||||
'No way to avoid same title OR same artist; using least recently played song.',
|
||||
[
|
||||
'media_id' => $validTrack->media_id,
|
||||
'title' => $validTrack->title,
|
||||
'artist' => $validTrack->artist,
|
||||
]
|
||||
);
|
||||
|
||||
return $validTrack;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BuildQueue $event
|
||||
*/
|
||||
|
@ -718,99 +633,6 @@ class Queue implements EventSubscriberInterface
|
|||
$event->setNextSong($stationQueueEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of eligible tracks, return the first ID that doesn't have a duplicate artist/
|
||||
* title with any of the previously played tracks.
|
||||
*
|
||||
* Both should be in the form of an array, i.e.:
|
||||
* [ 'id' => ['artist' => 'Foo', 'title' => 'Fighters'] ]
|
||||
*
|
||||
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
|
||||
* @param array $playedTracks
|
||||
*
|
||||
*/
|
||||
public static function getDistinctTrack(
|
||||
array $eligibleTracks,
|
||||
array $playedTracks
|
||||
): ?Entity\Api\StationPlaylistQueue {
|
||||
$artistSeparators = [
|
||||
', ',
|
||||
' feat ',
|
||||
' feat. ',
|
||||
' & ',
|
||||
' vs. ',
|
||||
];
|
||||
$dividerString = chr(7);
|
||||
|
||||
$artists = [];
|
||||
$titles = [];
|
||||
$latestSongIdsPlayed = [];
|
||||
|
||||
foreach ($playedTracks as $playedTrack) {
|
||||
$title = trim($playedTrack['title']);
|
||||
$titles[$title] = $title;
|
||||
|
||||
$artistParts = explode(
|
||||
$dividerString,
|
||||
str_replace($artistSeparators, $dividerString, $playedTrack['artist'])
|
||||
);
|
||||
|
||||
foreach ($artistParts as $artist) {
|
||||
$artist = trim($artist);
|
||||
if (!empty($artist)) {
|
||||
$artists[$artist] = $artist;
|
||||
}
|
||||
}
|
||||
|
||||
$songId = $playedTrack['song_id'];
|
||||
if (!isset($latestSongIdsPlayed[$songId])) {
|
||||
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'] ?? $playedTrack['timestamp_end'];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Entity\Api\StationPlaylistQueue[] $eligibleTracksWithoutSameTitle */
|
||||
$eligibleTracksWithoutSameTitle = [];
|
||||
|
||||
foreach ($eligibleTracks as $track) {
|
||||
// Avoid all direct title matches.
|
||||
$title = trim($track->title);
|
||||
|
||||
if (isset($titles[$title])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt to avoid an artist match, if possible.
|
||||
$artist = trim($track->artist);
|
||||
|
||||
$artistMatchFound = false;
|
||||
if (!empty($artist)) {
|
||||
$artistParts = explode($dividerString, str_replace($artistSeparators, $dividerString, $artist));
|
||||
foreach ($artistParts as $artist) {
|
||||
$artist = trim($artist);
|
||||
if (empty($artist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($artists[$artist])) {
|
||||
$artistMatchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$artistMatchFound) {
|
||||
return $track;
|
||||
}
|
||||
|
||||
$songId = $track->song_id;
|
||||
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
|
||||
$eligibleTracksWithoutSameTitle[$trackKey] = $track;
|
||||
}
|
||||
|
||||
ksort($eligibleTracksWithoutSameTitle);
|
||||
return array_shift($eligibleTracksWithoutSameTitle);
|
||||
}
|
||||
|
||||
protected function logRecentSongHistory(
|
||||
array $recentPlaylistHistory,
|
||||
array $recentSongHistoryForDuplicatePrevention
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace Unit;
|
||||
|
||||
use App\Entity\Api\StationPlaylistQueue;
|
||||
use App\Radio\AutoDJ\DuplicatePrevention;
|
||||
use App\Tests\Module;
|
||||
use Codeception\Test\Unit;
|
||||
|
||||
class DuplicatePreventionTest extends Unit
|
||||
{
|
||||
protected \UnitTester $tester;
|
||||
|
||||
protected DuplicatePrevention $duplicatePrevention;
|
||||
|
||||
protected function _inject(Module $tests_module): void
|
||||
{
|
||||
$di = $tests_module->container;
|
||||
$this->duplicatePrevention = $di->get(DuplicatePrevention::class);
|
||||
}
|
||||
|
||||
public function testDistinctTracks(): void
|
||||
{
|
||||
$eligibleTrack = new StationPlaylistQueue();
|
||||
$eligibleTrack->artist = 'Foo Fighters feat. AzuraCast Testers';
|
||||
$eligibleTrack->title = 'Best of You';
|
||||
$eligibleTracks = [$eligibleTrack];
|
||||
|
||||
$fullDuplicateTest = [
|
||||
[
|
||||
'title' => 'Best of You',
|
||||
'artist' => 'Foo Fighters',
|
||||
],
|
||||
];
|
||||
$fullDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $fullDuplicateTest);
|
||||
$this->assertNull($fullDuplicateResult);
|
||||
|
||||
$artistDuplicateTest = [
|
||||
[
|
||||
'title' => 'Everlong',
|
||||
'artist' => 'Foo Fighters',
|
||||
],
|
||||
];
|
||||
$artistDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $artistDuplicateTest);
|
||||
$this->assertNull($artistDuplicateResult);
|
||||
|
||||
$partialDuplicateTest = [
|
||||
[
|
||||
'title' => 'Testing Song',
|
||||
'artist' => 'Foo Fighters feat. Fall Out Boy',
|
||||
],
|
||||
];
|
||||
$partialDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $partialDuplicateTest);
|
||||
$this->assertNull($partialDuplicateResult);
|
||||
|
||||
$noDuplicatesTest = [
|
||||
[
|
||||
'title' => 'Testing Song 1',
|
||||
'artist' => 'Panic! at the Disco',
|
||||
],
|
||||
[
|
||||
'title' => 'Lost Memory',
|
||||
'artist' => '削除',
|
||||
],
|
||||
];
|
||||
$noDuplicatesResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $noDuplicatesTest);
|
||||
$this->assertNotNull($noDuplicatesResult);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue