mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
414 lines
16 KiB
PHP
414 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Gazelle\Artist;
|
|
|
|
class Similar extends \Gazelle\Base {
|
|
/**
|
|
* Two similar artists share the same similar id. This id is
|
|
* generated by inserting a row into artists_similar_scores.
|
|
* This id is then used to store the two artists in the
|
|
* artists_similar table. To make it trivial to find the pair
|
|
* regardless of which artist point of view is used, two
|
|
* records are stored in that table.
|
|
*
|
|
* When artist id 29 is similar to artist id 72, a row is
|
|
* inserted into artists_similar_scores, giving a similar id
|
|
* of e.g. 6. In the artists_similar table, the tuples
|
|
* (6, 29) and (6, 72) are stored.
|
|
*
|
|
* To avoid confusion between the id of similarity and the
|
|
* id of the similar artist, the latter is always referred to
|
|
* as "other" in the code.
|
|
*
|
|
* This is why, when looking for artists similar to id 29,
|
|
* the join is equality on SimilarID and inequality on ArtistID.
|
|
*/
|
|
protected const CACHE_KEY = 'artsim_%d';
|
|
protected const POSITION_KEY = 'artpos_%d';
|
|
|
|
protected array $info;
|
|
|
|
public function __construct(
|
|
protected \Gazelle\Artist $artist,
|
|
) {}
|
|
|
|
public function flush(): static {
|
|
self::$cache->delete_multi([
|
|
sprintf(self::CACHE_KEY, $this->artist->id),
|
|
sprintf(self::POSITION_KEY, $this->artist->id),
|
|
]);
|
|
unset($this->info);
|
|
return $this;
|
|
}
|
|
|
|
public function artist(): \Gazelle\Artist {
|
|
return $this->artist;
|
|
}
|
|
|
|
public function id(): int {
|
|
return $this->artist->id;
|
|
}
|
|
|
|
public function info(): array {
|
|
if (isset($this->info)) {
|
|
return $this->info;
|
|
}
|
|
$key = sprintf(self::CACHE_KEY, $this->artist->id);
|
|
$info = self::$cache->get_value($key);
|
|
if ($info === false) {
|
|
self::$db->prepared_query("
|
|
SELECT s2.ArtistID AS artist_id,
|
|
aa.Name AS name,
|
|
ass.Score AS score,
|
|
ass.SimilarID AS similar_id
|
|
FROM artists_similar AS s1
|
|
INNER JOIN artists_similar AS s2 ON (s1.SimilarID = s2.SimilarID AND s1.ArtistID != s2.ArtistID)
|
|
INNER JOIN artists_similar_scores AS ass ON (ass.SimilarID = s1.SimilarID)
|
|
INNER JOIN artists_group AS a ON (a.ArtistID = s2.ArtistID)
|
|
INNER JOIN artists_alias aa ON (a.PrimaryAlias = aa.AliasID)
|
|
WHERE s1.ArtistID = ?
|
|
ORDER BY ass.Score DESC, aa.Name
|
|
LIMIT 30
|
|
", $this->artist->id
|
|
);
|
|
$info = self::$db->to_array(false, MYSQLI_ASSOC);
|
|
}
|
|
$this->info = $info;
|
|
return $this->info;
|
|
}
|
|
|
|
public function findSimilarId(\Gazelle\Artist $other): int {
|
|
$artist = array_values(array_filter($this->info(), fn($s) => $s['artist_id'] == $other->id));
|
|
return $artist[0]['similar_id'] ?? 0;
|
|
}
|
|
|
|
public function addSimilar(\Gazelle\Artist $other, \Gazelle\User $user): int {
|
|
$thisId = $this->artist->id;
|
|
$otherId = $other->id;
|
|
self::$db->begin_transaction();
|
|
$findId = $this->findSimilarId($other);
|
|
if ($findId) {
|
|
// The similar artists field already exists, if the person adding
|
|
// has not added or voted before, consider it an upvote for the
|
|
// existing similarity
|
|
self::$db->prepared_query("
|
|
UPDATE artists_similar_scores SET
|
|
Score = Score + 200
|
|
WHERE SimilarID = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM artists_similar_votes WHERE SimilarID = ? AND UserID = ?
|
|
)
|
|
", $findId, $findId, $user->id
|
|
);
|
|
} else {
|
|
// No, it doesn't exist - create it
|
|
self::$db->prepared_query("
|
|
INSERT INTO artists_similar_scores (Score) VALUES (200)
|
|
");
|
|
$findId = self::$db->inserted_id();
|
|
self::$db->prepared_query("
|
|
INSERT INTO artists_similar
|
|
(ArtistID, SimilarID)
|
|
VALUES (?, ?), (?, ?)
|
|
", $thisId, $findId, $otherId, $findId
|
|
);
|
|
$this->logger()->general(
|
|
"User {$user->label()} set artist {$this->artist()->label()} similar to artist {$other->label()}"
|
|
);
|
|
}
|
|
self::$db->prepared_query("
|
|
INSERT IGNORE INTO artists_similar_votes
|
|
(SimilarID, UserID, way)
|
|
VALUES (?, ?, 'up')
|
|
", $findId, $user->id
|
|
);
|
|
$affected = self::$db->affected_rows();
|
|
self::$db->commit();
|
|
$this->flush();
|
|
$other->flush();
|
|
return $affected;
|
|
}
|
|
|
|
public function voteSimilar(\Gazelle\User $user, \Gazelle\Artist $other, bool $upvote): bool {
|
|
$similarId = $this->findSimilarId($other);
|
|
if (!$similarId) {
|
|
return false;
|
|
}
|
|
|
|
// if the vote already exists in this direction: do nothing
|
|
$vote = $upvote ? 'up' : 'down';
|
|
if (
|
|
(bool)self::$db->scalar("
|
|
SELECT 1
|
|
FROM artists_similar_votes
|
|
WHERE SimilarID = ?
|
|
AND UserID = ?
|
|
AND Way = ?
|
|
", $similarId, $user->id, $vote
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// if the new vote is in the opposite direction of the old one,
|
|
// remove the previous vote
|
|
$opposite = !$upvote ? 'up' : 'down';
|
|
if (
|
|
(bool)self::$db->scalar("
|
|
SELECT 1
|
|
FROM artists_similar_votes
|
|
WHERE SimilarID = ?
|
|
AND UserID = ?
|
|
AND Way = ?
|
|
", $similarId, $user->id, $opposite
|
|
)
|
|
) {
|
|
self::$db->begin_transaction();
|
|
self::$db->prepared_query("
|
|
UPDATE artists_similar_scores SET
|
|
Score = Score + ?
|
|
WHERE SimilarID = ?
|
|
", $upvote ? -100 : 100, $similarId
|
|
);
|
|
self::$db->prepared_query("
|
|
DELETE FROM artists_similar_votes
|
|
WHERE SimilarID = ?
|
|
AND UserID = ?
|
|
AND Way = ?
|
|
", $similarId, $user->id, $opposite
|
|
);
|
|
self::$db->commit();
|
|
} else {
|
|
// there is no vote: record it
|
|
self::$db->begin_transaction();
|
|
self::$db->prepared_query("
|
|
UPDATE artists_similar_scores SET
|
|
Score = Score + ?
|
|
WHERE SimilarID = ?
|
|
", $upvote ? 100 : -100, $similarId
|
|
);
|
|
self::$db->prepared_query("
|
|
INSERT INTO artists_similar_votes
|
|
(SimilarID, UserID, Way)
|
|
VALUES (?, ?, ?)
|
|
", $similarId, $user->id, $vote
|
|
);
|
|
self::$db->commit();
|
|
}
|
|
|
|
$this->flush();
|
|
$other->flush();
|
|
return true;
|
|
}
|
|
|
|
public function removeSimilar(\Gazelle\Artist $other, \Gazelle\User $user): bool {
|
|
$similarId = $this->findSimilarId($other);
|
|
if (!$similarId) {
|
|
return false;
|
|
}
|
|
self::$db->prepared_query("
|
|
DELETE FROM artists_similar_scores WHERE SimilarID = ?
|
|
", $similarId
|
|
);
|
|
$this->logger()->general(
|
|
"User {$user->label()} removed artist {$this->artist()->label()} similar to artist {$other->label()}"
|
|
);
|
|
$this->flush();
|
|
$other->flush();
|
|
return true;
|
|
}
|
|
|
|
public function similarGraph(int $width, int $height): array {
|
|
// find the similar artists of this one
|
|
self::$db->prepared_query("
|
|
SELECT s2.ArtistID AS artist_id,
|
|
aa.Name AS artist_name,
|
|
ass.Score AS score,
|
|
count(asv.SimilarID) AS votes
|
|
FROM artists_similar s1
|
|
INNER join artists_similar s2 ON (s1.SimilarID = s2.SimilarID AND s1.ArtistID != s2.ArtistID)
|
|
INNER JOIN artists_group AS a ON (a.ArtistID = s2.ArtistID)
|
|
INNER JOIN artists_alias aa ON (a.PrimaryAlias = aa.AliasID)
|
|
INNER JOIN artists_similar_scores ass ON (ass.SimilarID = s1.SimilarID)
|
|
INNER JOIN artists_similar_votes asv ON (asv.SimilarID = s1.SimilarID)
|
|
WHERE s1.ArtistID = ?
|
|
AND ass.Score > 0
|
|
GROUP BY s1.SimilarID
|
|
ORDER BY score DESC,
|
|
votes DESC
|
|
LIMIT 30
|
|
", $this->artist->id
|
|
);
|
|
$artistIds = self::$db->collect('artist_id') ?: [0];
|
|
$similar = self::$db->to_array('artist_id', MYSQLI_ASSOC);
|
|
if (!$similar) {
|
|
return [];
|
|
}
|
|
$nrSimilar = count($similar);
|
|
|
|
// of these similar artists, see if any are similar to each other
|
|
self::$db->prepared_query("
|
|
SELECT s1.artistid AS source,
|
|
group_concat(s2.artistid) AS target
|
|
FROM artists_similar s1
|
|
INNER JOIN artists_similar s2 ON (s1.similarid = s2.similarid and s1.artistid != s2.artistid)
|
|
WHERE s1.artistid in (" . placeholders($artistIds) . ")
|
|
AND s2.artistid in (" . placeholders($artistIds) . ")
|
|
GROUP BY s1.artistid
|
|
", ...array_merge($artistIds, $artistIds)
|
|
);
|
|
$relation = self::$db->to_array('source', MYSQLI_ASSOC);
|
|
|
|
// calculate some minimax stuff to figure out line lengths
|
|
$max = 0;
|
|
$min = null;
|
|
$totalScore = 0;
|
|
foreach ($similar as &$s) {
|
|
$s['related'] = [];
|
|
$s['nrRelated'] = 0;
|
|
$max = max($max, $s['score']);
|
|
if (is_null($min)) {
|
|
$min = $s['score'];
|
|
} else {
|
|
$min = min($min, $s['score']);
|
|
}
|
|
$totalScore += $s['score'];
|
|
}
|
|
unset($s);
|
|
|
|
// Use the golden ratio formula to generate the angles where the
|
|
// artists will be placed (to avoid drawing a line through the
|
|
// origin for a relation when there are an even number of artists).
|
|
// Sort the results because a) the order will be vaguely chaotic,
|
|
// and b) we have a guarantee that two adjacent angles will be
|
|
// at the beginning and end of the array (as long as we alternate
|
|
// between shifting and popping the array).
|
|
$layout = [];
|
|
$angle = fmod($this->artist->id, 2 * M_PI);
|
|
$golden = M_PI * (3 - sqrt(5));
|
|
foreach (range(0, $nrSimilar - 1) as $r) {
|
|
$layout[] = $angle;
|
|
$angle = fmod($angle + $golden, 2 * M_PI);
|
|
}
|
|
sort($layout);
|
|
|
|
// Thread all the similar artists with their related artists
|
|
// and sort those with the most relations first.
|
|
foreach ($relation as $source => $targetList) {
|
|
$t = explode(',', $targetList['target']);
|
|
foreach ($t as $target) {
|
|
$similar[$source]['related'][] = (int)$target;
|
|
$similar[$source]['nrRelated']++;
|
|
}
|
|
}
|
|
|
|
// For all artists with relations, sort their relations list by least relations first.
|
|
// The idea is to have other artists that are only related to this one close by.
|
|
foreach ($similar as &$s) {
|
|
if ($s['nrRelated'] < 2) {
|
|
// trivial case
|
|
continue;
|
|
}
|
|
$related = $s['related'];
|
|
usort($related, fn ($a, $b) => $similar[$a]['nrRelated'] <=> $similar[$b]['nrRelated']);
|
|
$s['related'] = $related;
|
|
}
|
|
unset($s);
|
|
|
|
// Now sort the artists by most relations first
|
|
uksort($similar, fn ($a, $b)
|
|
=> ($similar[$b]['nrRelated'] <=> $similar[$a]['nrRelated'] ?: $similar[$b]['score'] <=> $similar[$a]['score'])
|
|
?: $similar[$b]['artist_id'] <=> $similar[$a]['artist_id']
|
|
);
|
|
|
|
// Place the artists with the most relations first, and place
|
|
// their relations near them, alternating on each side.
|
|
$xOrigin = $width / 2;
|
|
$yOrigin = $height / 2;
|
|
$range = ($max === $min) ? $max : $max - $min;
|
|
$placed = array_fill_keys(array_keys($similar), false);
|
|
$seen = 0;
|
|
foreach ($similar as &$s) {
|
|
$id = $s['artist_id'];
|
|
if ($placed[$id] !== false) {
|
|
continue;
|
|
}
|
|
$relatedToPlace = 0;
|
|
$relatedTotal = 0;
|
|
foreach ($s['related'] as $r) {
|
|
$relatedTotal++;
|
|
if ($placed[$r] === false) {
|
|
$relatedToPlace++;
|
|
}
|
|
}
|
|
if ($relatedToPlace > 0) {
|
|
// Rotate the layout angles to fit this artist in, so that we can
|
|
// pick the first and last angles off the layout list below.
|
|
$move = (int)ceil(($relatedToPlace + 1) / 2);
|
|
$layout = [...array_slice($layout, $move, null, true), ...array_slice($layout, 0, $move, true)];
|
|
}
|
|
if (!($relatedTotal > 0 && $seen > 1)) {
|
|
$angle = array_shift($layout);
|
|
$up = false;
|
|
} else {
|
|
// By now we have already placed two artists and we are here because the
|
|
// current artist has a related artist to place. Have a look at the previously
|
|
// placed artists, and if this artist is related to them, then choose first
|
|
// or last angle in the layout list to place this artist close to them.
|
|
$nextAngle = reset($layout);
|
|
$prevAngle = end($layout);
|
|
$bestNextAngle = 2 * M_PI;
|
|
$bestPrevAngle = 2 * M_PI;
|
|
foreach ($s['related'] as $r) {
|
|
if ($placed[$r] === false) {
|
|
continue;
|
|
}
|
|
$nextAngleDistance = fmod($nextAngle + $placed[$r], 2 * M_PI); /** @phpstan-ignore-line */
|
|
$prevAngleDistance = fmod($prevAngle + $placed[$r], 2 * M_PI); /** @phpstan-ignore-line */
|
|
if ($nextAngleDistance <= $prevAngleDistance) {
|
|
$bestNextAngle = min($bestNextAngle, $nextAngleDistance);
|
|
} else {
|
|
$bestPrevAngle = min($bestPrevAngle, $prevAngleDistance);
|
|
}
|
|
}
|
|
if (fmod($bestNextAngle, 2 * M_PI) < fmod($bestPrevAngle, 2 * M_PI)) {
|
|
$angle = array_shift($layout);
|
|
$up = false;
|
|
} else {
|
|
$angle = array_pop($layout);
|
|
$up = true;
|
|
}
|
|
}
|
|
$placed[$id] = $angle;
|
|
++$seen;
|
|
|
|
// place this artist
|
|
$distance = 0.9 - (($s['score'] - $min) * 0.4 / $range);
|
|
$s['x'] = (int)(cos($angle) * $distance * $xOrigin) + $xOrigin;
|
|
$s['y'] = (int)(sin($angle) * $distance * $yOrigin) + $yOrigin;
|
|
$s['proportion'] = ($s['score'] / ($totalScore + 1)) ** 1.0;
|
|
|
|
// Place their related close by, first anti-clockwise (angle
|
|
// increasing: array_shift(), next clockwise (angle decreasing:
|
|
// array_pop() and repeat until done.
|
|
// There might be a way to refactor this to avoid repetition.
|
|
foreach ($s['related'] as $r) {
|
|
if ($placed[$r] !== false) {
|
|
continue;
|
|
}
|
|
$angle = $up ? array_shift($layout) : array_pop($layout);
|
|
$up = !$up;
|
|
$placed[$r] = $angle;
|
|
++$seen;
|
|
|
|
// place this related artist
|
|
$distance = 0.9 - (($similar[$r]['score'] - $min) * 0.45 / $range);
|
|
$similar[$r]['x'] = (int)(cos($angle) * $distance * $xOrigin) + $xOrigin;
|
|
$similar[$r]['y'] = (int)(sin($angle) * $distance * $yOrigin) + $yOrigin;
|
|
$similar[$r]['proportion'] = ($similar[$r]['score'] / ($totalScore + 1)) ** 1.0;
|
|
}
|
|
}
|
|
return $similar;
|
|
}
|
|
}
|