Passed
Push — master ( a4260e...ab8f86 )
by Pauli
13:01
created

PlaylistBusinessLayer::favoriteMask()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 6
c 0
b 0
f 0
nc 5
nop 1
dl 0
loc 7
rs 9.6111
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 - 2024
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::Name, 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 $history One of: 'recently-played', 'not-recently-played', 'often-played', 'rarely-played', 'recently-added', 'not-recently-added'
190
	 * @param bool $historyStrict In the "strict" mode, there's no element of randomness when applying the history filter and e.g.
191
	 *             'recently-played' meas "The most recently played" instead of "Among the most recently played"
192
	 * @param int[] $genres Array of genre IDs
193
	 * @param int[] $artists Array of artist IDs
194
	 * @param int|null $fromYear Earliest release year to include
195
	 * @param int|null $toYear Latest release year to include
196
	 * @param string|null $favorite One of: 'track', 'album', 'artists', 'track_album_artist', null
197
	 * @param int $size Size of the playlist to generate, provided that there are enough matching tracks
198
	 * @param string $userId the name of the user
199
	 */
200
	public function generate(
201
			?string $history, bool $historyStrict, array $genres, array $artists,
202
			?int $fromYear, ?int $toYear, ?string $favorite, int $size, string $userId) : Playlist {
203
204
		$now = new \DateTime();
205
		$nowStr = $now->format(PlaylistMapper::SQL_DATE_FORMAT);
206
207
		$playlist = new Playlist();
208
		$playlist->setCreated($nowStr);
209
		$playlist->setUpdated($nowStr);
210
		$playlist->setName('Generated ' . $nowStr);
211
		$playlist->setUserId($userId);
212
213
		list('sortBy' => $sortBy, 'invert' => $invertSort) = self::sortRulesForHistory($history);
214
		$limit = ($sortBy === SortBy::None) ? null : ($historyStrict ? $size : $size * 4);
215
216
		$favoriteMask = self::favoriteMask($favorite);
217
218
		$tracks = $this->trackMapper->findAllByCriteria($genres, $artists, $fromYear, $toYear, $favoriteMask, $sortBy, $invertSort, $userId, $limit);
219
220
		if ($sortBy !== SortBy::None && !$historyStrict) {
221
			// When generating by non-strict history, use a pool of tracks at maximum twice the size of final list.
222
			// However, don't use more than half of the matching tracks unless that is required to satisfy the required list size.
223
			$poolSize = max($size, \count($tracks) / 2);
224
			$tracks = \array_slice($tracks, 0, $poolSize);
225
		}
226
227
		// Pick the final random set of tracks
228
		$tracks = Random::pickItems($tracks, $size);
229
230
		$playlist->setTrackIdsFromArray(Util::extractIds($tracks));
231
232
		return $playlist;
233
	}
234
235
	private static function sortRulesForHistory(?string $history) : array {
236
		switch ($history) {
237
			case 'recently-played':
238
				return ['sortBy' => SortBy::LastPlayed, 'invert' => false];
239
			case 'not-recently-played':
240
				return ['sortBy' => SortBy::LastPlayed, 'invert' => true];
241
			case 'often-played':
242
				return ['sortBy' => SortBy::PlayCount, 'invert' => false];
243
			case 'rarely-played':
244
				return ['sortBy' => SortBy::PlayCount, 'invert' => true];
245
			case 'recently-added':
246
				return ['sortBy' => SortBy::Newest, 'invert' => false];
247
			case 'not-recently-added':
248
				return ['sortBy' => SortBy::Newest, 'invert' => true];
249
			default:
250
				return ['sortBy' => SortBy::None, 'invert' => false];
251
		}
252
	}
253
254
	private static function favoriteMask(?string $mode) : ?int {
255
		switch ($mode) {
256
			case 'track':				return TrackMapper::FAVORITE_TRACK;
257
			case 'album':				return TrackMapper::FAVORITE_ALBUM;
258
			case 'artist':				return TrackMapper::FAVORITE_ARTIST;
259
			case 'track_album_artist':	return TrackMapper::FAVORITE_TRACK | TrackMapper::FAVORITE_ALBUM | TrackMapper::FAVORITE_ARTIST;
260
			default:					return null;
261
		}
262
	}
263
}
264