Passed
Push — feature/1061-random-view ( afd67b...1c2ae7 )
by Pauli
03:33
created

PlaylistBusinessLayer::generate()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 16
c 0
b 0
f 0
nc 4
nop 6
dl 0
loc 28
rs 9.7333
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2016 - 2023
11
 */
12
13
namespace OCA\Music\BusinessLayer;
14
15
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
16
use OCA\Music\AppFramework\Core\Logger;
17
18
use OCA\Music\Db\Playlist;
19
use OCA\Music\Db\PlaylistMapper;
20
use OCA\Music\Db\SortBy;
21
use OCA\Music\Db\Track;
22
use OCA\Music\Db\TrackMapper;
23
24
use OCA\Music\Utility\Random;
25
use OCA\Music\Utility\Util;
26
27
/**
28
 * Base class functions with actually used inherited types to help IDE and Scrutinizer:
29
 * @method Playlist find(int $playlistId, string $userId)
30
 * @method Playlist[] findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null)
31
 * @method Playlist[] findAllByName(string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null)
32
 * @phpstan-extends BusinessLayer<Playlist>
33
 */
34
class PlaylistBusinessLayer extends BusinessLayer {
35
	protected $mapper; // eclipse the definition from the base class, to help IDE and Scrutinizer to know the actual type
36
	private $trackMapper;
37
	private $logger;
38
39
	public function __construct(
40
			PlaylistMapper $playlistMapper,
41
			TrackMapper $trackMapper,
42
			Logger $logger) {
43
		parent::__construct($playlistMapper);
44
		$this->mapper = $playlistMapper;
45
		$this->trackMapper = $trackMapper;
46
		$this->logger = $logger;
47
	}
48
49
	public function setTracks(array $trackIds, int $playlistId, string $userId) : Playlist {
50
		$playlist = $this->find($playlistId, $userId);
51
		$playlist->setTrackIdsFromArray($trackIds);
52
		$this->mapper->update($playlist);
53
		return $playlist;
54
	}
55
56
	public function addTracks(array $trackIds, int $playlistId, string $userId) : Playlist {
57
		$playlist = $this->find($playlistId, $userId);
58
		$prevTrackIds = $playlist->getTrackIdsAsArray();
59
		$playlist->setTrackIdsFromArray(\array_merge($prevTrackIds, $trackIds));
60
		$this->mapper->update($playlist);
61
		return $playlist;
62
	}
63
64
	public function removeTracks(array $trackIndices, int $playlistId, string $userId) : Playlist {
65
		$playlist = $this->find($playlistId, $userId);
66
		$trackIds = $playlist->getTrackIdsAsArray();
67
		$trackIds = \array_diff_key($trackIds, \array_flip($trackIndices));
68
		$playlist->setTrackIdsFromArray($trackIds);
69
		$this->mapper->update($playlist);
70
		return $playlist;
71
	}
72
73
	public function removeAllTracks(int $playlistId, string $userId) : Playlist {
74
		$playlist = $this->find($playlistId, $userId);
75
		$playlist->setTrackIdsFromArray([]);
76
		$this->mapper->update($playlist);
77
		return $playlist;
78
	}
79
80
	public function moveTrack(int $fromIndex, int $toIndex, int $playlistId, string $userId) : Playlist {
81
		$playlist = $this->find($playlistId, $userId);
82
		$trackIds = $playlist->getTrackIdsAsArray();
83
		$movedTrack = \array_splice($trackIds, $fromIndex, 1);
84
		\array_splice($trackIds, $toIndex, 0, $movedTrack);
85
		$playlist->setTrackIdsFromArray($trackIds);
86
		$this->mapper->update($playlist);
87
		return $playlist;
88
	}
89
90
	public function create(string $name, string $userId) : Playlist {
91
		$playlist = new Playlist();
92
		$playlist->setName(Util::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
93
		$playlist->setUserId($userId);
94
95
		return $this->mapper->insert($playlist);
96
	}
97
98
	public function rename(string $name, int $playlistId, string $userId) : Playlist {
99
		$playlist = $this->find($playlistId, $userId);
100
		$playlist->setName(Util::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
101
		$this->mapper->update($playlist);
102
		return $playlist;
103
	}
104
105
	public function setComment(string $comment, int $playlistId, string $userId) : Playlist {
106
		$playlist = $this->find($playlistId, $userId);
107
		$playlist->setComment(Util::truncate($comment, 256)); // some DB setups can't truncate automatically to column max size
108
		$this->mapper->update($playlist);
109
		return $playlist;
110
	}
111
112
	/**
113
	 * removes tracks from all available playlists
114
	 * @param int[] $trackIds array of all track IDs to remove
115
	 */
116
	public function removeTracksFromAllLists(array $trackIds) : void {
117
		foreach ($trackIds as $trackId) {
118
			$affectedLists = $this->mapper->findListsContainingTrack($trackId);
119
120
			foreach ($affectedLists as $playlist) {
121
				$prevTrackIds = $playlist->getTrackIdsAsArray();
122
				$playlist->setTrackIdsFromArray(\array_diff($prevTrackIds, [$trackId]));
123
				$this->mapper->update($playlist);
124
			}
125
		}
126
	}
127
128
	/**
129
	 * get list of Track objects belonging to a given playlist
130
	 * @return Track[]
131
	 */
132
	public function getPlaylistTracks(int $playlistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
133
		$playlist = $this->find($playlistId, $userId);
134
		$trackIds = $playlist->getTrackIdsAsArray();
135
136
		$trackIds = \array_slice($trackIds, \intval($offset), $limit);
137
138
		$tracks = empty($trackIds) ? [] : $this->trackMapper->findById($trackIds, $userId);
139
140
		// The $tracks contains the songs in unspecified order and with no duplicates.
141
		// Build a new array where the tracks are in the same order as in $trackIds.
142
		$tracksById = Util::createIdLookupTable($tracks);
143
144
		$playlistTracks = [];
145
		foreach ($trackIds as $index => $trackId) {
146
			$track = $tracksById[$trackId] ?? null;
147
			if ($track !== null) {
148
				// in case the same track comes up again in the list, clone the track object
149
				// to have different numbers on the instances
150
				if ($track->getNumberOnPlaylist() !== null) {
151
					$track = clone $track;
152
				}
153
				$track->setNumberOnPlaylist(\intval($offset) + $index + 1);
154
			} else {
155
				$this->logger->log("Invalid track ID $trackId found on playlist $playlistId", 'debug');
156
				$track = new Track();
157
				$track->setId($trackId);
158
			}
159
			$playlistTracks[] = $track;
160
		}
161
162
		return $playlistTracks;
163
	}
164
165
	/**
166
	 * get the total duration of all the tracks on a playlist
167
	 *
168
	 * @return int duration in seconds
169
	 */
170
	public function getDuration(int $playlistId, string $userId) : int {
171
		$playlist = $this->find($playlistId, $userId);
172
		$trackIds = $playlist->getTrackIdsAsArray();
173
		$durations = $this->trackMapper->getDurations($trackIds);
174
175
		// We can't simply sum up the values of $durations array, because the playlist may
176
		// contain duplicate entries, and those are not reflected in $durations.
177
		// Be also prepared to invalid playlist entries where corresponding track length does not exist.
178
		$sum = 0;
179
		foreach ($trackIds as $trackId) {
180
			$sum += $durations[$trackId] ?? 0;
181
		}
182
183
		return $sum;
184
	}
185
186
	/**
187
	 * Generate and return a playlist matching the given criteria. The playlist is not persisted.
188
	 *
189
	 * @param string|null $playRate One of: 'recent', 'not-recent', 'often', 'rarely'
190
	 * @param int[]|null $genres Array of genre IDs
191
	 * @param int|null $fromYear Earliest release year to include
192
	 * @param int|null $toYear Latest release year to include
193
	 * @param int $size Size of the playlist to generate, provided that there are enough matching tracks
194
	 * @param string $userId the name of the user
195
	 */
196
	public function generate(?string $playRate, array $genres, ?int $fromYear, ?int $toYear, int $size, string $userId) : Playlist {
197
		$now = new \DateTime();
198
		$nowStr = $now->format(PlaylistMapper::SQL_DATE_FORMAT);
199
200
		$playlist = new Playlist();
201
		$playlist->setCreated($nowStr);
202
		$playlist->setUpdated($nowStr);
203
		$playlist->setName('Generated ' . $nowStr);
204
		$playlist->setUserId($userId);
205
206
		list('sortBy' => $sortBy, 'invert' => $invertSort) = self::sortRulesForPlayRate($playRate);
207
		$limit = ($sortBy === SortBy::None) ? null : $size * 4;
208
209
		$tracks = $this->trackMapper->findAllByCriteria($genres, $fromYear, $toYear, $sortBy, $invertSort, $userId, $limit);
210
211
		if ($sortBy !== SortBy::None) {
212
			// When generating by play-rate, use a pool of tracks at maximum twice the size of final list. However, don't use
213
			// more than half of the matching tracks unless that is required to satisfy the required list size.
214
			$poolSize = max($size, \count($tracks) / 2);
215
			$tracks = \array_slice($tracks, 0, $poolSize);
216
		}
217
218
		// Pick the final random set of tracks
219
		$tracks = Random::pickItems($tracks, $size);
220
221
		$playlist->setTrackIdsFromArray(Util::extractIds($tracks));
222
223
		return $playlist;
224
	}
225
226
	private static function sortRulesForPlayRate(?string $playRate) : array {
227
		switch ($playRate) {
228
			case 'recently':
229
				return ['sortBy' => SortBy::LastPlayed, 'invert' => true];
230
			case 'not-recently':
231
				return ['sortBy' => SortBy::LastPlayed, 'invert' => false];
232
			case 'often':
233
				return ['sortBy' => SortBy::PlayCount, 'invert' => true];
234
			case 'rarely':
235
				return ['sortBy' => SortBy::PlayCount, 'invert' => false];
236
			default:
237
				return ['sortBy' => SortBy::None, 'invert' => false];
238
		}
239
	}
240
}
241