Passed
Push — master ( 7b42d0...d80bc5 )
by Pauli
04:15 queued 21s
created

PlaylistBusinessLayer   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 236
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 117
c 0
b 0
f 0
dl 0
loc 236
rs 9.36
wmc 38

16 Methods

Rating   Name   Duplication   Size   Complexity  
A setTracks() 0 5 1
A __construct() 0 7 1
A create() 0 6 1
A setComment() 0 5 1
A addTracks() 0 11 2
A rename() 0 5 1
A removeTracks() 0 7 1
A removeAllTracks() 0 5 1
A moveTrack() 0 8 1
A removeTracksFromAllLists() 0 8 3
A generate() 0 33 5
A getDuration() 0 13 2
A getPlaylistTrackIds() 0 3 1
A getPlaylistTracks() 0 30 5
A favoriteMask() 0 7 5
B sortRulesForHistory() 0 16 7
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 - 2025
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\MatchMode;
19
use OCA\Music\Db\Playlist;
20
use OCA\Music\Db\PlaylistMapper;
21
use OCA\Music\Db\SortBy;
22
use OCA\Music\Db\Track;
23
use OCA\Music\Db\TrackMapper;
24
use OCA\Music\Utility\ArrayUtil;
25
use OCA\Music\Utility\Random;
26
use OCA\Music\Utility\StringUtil;
27
28
/**
29
 * Base class functions with actually used inherited types to help IDE and Scrutinizer:
30
 * @method Playlist find(int $playlistId, string $userId)
31
 * @method Playlist[] findAll(string $userId, int $sortBy=SortBy::Name, ?int $limit=null, ?int $offset=null)
32
 * @method Playlist[] findAllByName(string $name, string $userId, int $matchMode=MatchMode::Exact, ?int $limit=null, ?int $offset=null)
33
 * @property PlaylistMapper $mapper
34
 * @phpstan-extends BusinessLayer<Playlist>
35
 */
36
class PlaylistBusinessLayer extends BusinessLayer {
37
	private TrackMapper $trackMapper;
38
	private Logger $logger;
39
40
	public function __construct(
41
			PlaylistMapper $playlistMapper,
42
			TrackMapper $trackMapper,
43
			Logger $logger) {
44
		parent::__construct($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, ?int $insertIndex = null) : Playlist {
57
		$playlist = $this->find($playlistId, $userId);
58
		$allTrackIds = $playlist->getTrackIdsAsArray();
59
		if ($insertIndex === null) {
60
			$allTrackIds = \array_merge($allTrackIds, $trackIds);
61
		} else {
62
			\array_splice($allTrackIds, $insertIndex, 0, $trackIds);
63
		}
64
		$playlist->setTrackIdsFromArray($allTrackIds);
65
		$this->mapper->update($playlist);
66
		return $playlist;
67
	}
68
69
	public function removeTracks(array $trackIndices, int $playlistId, string $userId) : Playlist {
70
		$playlist = $this->find($playlistId, $userId);
71
		$trackIds = $playlist->getTrackIdsAsArray();
72
		$trackIds = \array_diff_key($trackIds, \array_flip($trackIndices));
73
		$playlist->setTrackIdsFromArray($trackIds);
74
		$this->mapper->update($playlist);
75
		return $playlist;
76
	}
77
78
	public function removeAllTracks(int $playlistId, string $userId) : Playlist {
79
		$playlist = $this->find($playlistId, $userId);
80
		$playlist->setTrackIdsFromArray([]);
81
		$this->mapper->update($playlist);
82
		return $playlist;
83
	}
84
85
	public function moveTrack(int $fromIndex, int $toIndex, int $playlistId, string $userId) : Playlist {
86
		$playlist = $this->find($playlistId, $userId);
87
		$trackIds = $playlist->getTrackIdsAsArray();
88
		$movedTrack = \array_splice($trackIds, $fromIndex, 1);
89
		\array_splice($trackIds, $toIndex, 0, $movedTrack);
90
		$playlist->setTrackIdsFromArray($trackIds);
91
		$this->mapper->update($playlist);
92
		return $playlist;
93
	}
94
95
	public function create(string $name, string $userId) : Playlist {
96
		$playlist = new Playlist();
97
		$playlist->setName(StringUtil::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
98
		$playlist->setUserId($userId);
99
100
		return $this->mapper->insert($playlist);
101
	}
102
103
	public function rename(string $name, int $playlistId, string $userId) : Playlist {
104
		$playlist = $this->find($playlistId, $userId);
105
		$playlist->setName(StringUtil::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
106
		$this->mapper->update($playlist);
107
		return $playlist;
108
	}
109
110
	public function setComment(string $comment, int $playlistId, string $userId) : Playlist {
111
		$playlist = $this->find($playlistId, $userId);
112
		$playlist->setComment(StringUtil::truncate($comment, 256)); // some DB setups can't truncate automatically to column max size
113
		$this->mapper->update($playlist);
114
		return $playlist;
115
	}
116
117
	/**
118
	 * removes tracks from all available playlists
119
	 * @param int[] $trackIds array of all track IDs to remove
120
	 */
121
	public function removeTracksFromAllLists(array $trackIds) : void {
122
		foreach ($trackIds as $trackId) {
123
			$affectedLists = $this->mapper->findListsContainingTrack($trackId);
124
125
			foreach ($affectedLists as $playlist) {
126
				$prevTrackIds = $playlist->getTrackIdsAsArray();
127
				$playlist->setTrackIdsFromArray(\array_diff($prevTrackIds, [$trackId]));
128
				$this->mapper->update($playlist);
129
			}
130
		}
131
	}
132
133
	/**
134
	 * @return int[]
135
	 */
136
	public function getPlaylistTrackIds(int $playlistId, string $userId) : array {
137
		$playlist = $this->find($playlistId, $userId);
138
		return $playlist->getTrackIdsAsArray();
139
	}
140
141
	/**
142
	 * get list of Track objects belonging to a given playlist
143
	 * @return Track[]
144
	 */
145
	public function getPlaylistTracks(int $playlistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
146
		$trackIds = $this->getPlaylistTrackIds($playlistId, $userId);
147
148
		$trackIds = \array_slice($trackIds, \intval($offset), $limit);
149
150
		$tracks = empty($trackIds) ? [] : $this->trackMapper->findById($trackIds, $userId);
151
152
		// The $tracks contains the songs in unspecified order and with no duplicates.
153
		// Build a new array where the tracks are in the same order as in $trackIds.
154
		$tracksById = ArrayUtil::createIdLookupTable($tracks);
155
156
		$playlistTracks = [];
157
		foreach ($trackIds as $index => $trackId) {
158
			$track = $tracksById[$trackId] ?? null;
159
			if ($track !== null) {
160
				// in case the same track comes up again in the list, clone the track object
161
				// to have different numbers on the instances
162
				if ($track->getNumberOnPlaylist() !== null) {
163
					$track = clone $track;
164
				}
165
				$track->setNumberOnPlaylist(\intval($offset) + $index + 1);
166
			} else {
167
				$this->logger->log("Invalid track ID $trackId found on playlist $playlistId", 'debug');
168
				$track = Track::emptyInstance();
169
				$track->setId($trackId);
170
			}
171
			$playlistTracks[] = $track;
172
		}
173
174
		return $playlistTracks;
175
	}
176
177
	/**
178
	 * get the total duration of all the tracks on a playlist
179
	 *
180
	 * @return int duration in seconds
181
	 */
182
	public function getDuration(int $playlistId, string $userId) : int {
183
		$trackIds = $this->getPlaylistTrackIds($playlistId, $userId);
184
		$durations = $this->trackMapper->getDurations($trackIds);
185
186
		// We can't simply sum up the values of $durations array, because the playlist may
187
		// contain duplicate entries, and those are not reflected in $durations.
188
		// Be also prepared to invalid playlist entries where corresponding track length does not exist.
189
		$sum = 0;
190
		foreach ($trackIds as $trackId) {
191
			$sum += $durations[$trackId] ?? 0;
192
		}
193
194
		return $sum;
195
	}
196
197
	/**
198
	 * Generate and return a playlist matching the given criteria. The playlist is not persisted.
199
	 *
200
	 * @param string|null $history One of: 'recently-played', 'not-recently-played', 'often-played', 'rarely-played', 'recently-added', 'not-recently-added'
201
	 * @param bool $historyStrict In the "strict" mode, there's no element of randomness when applying the history filter and e.g.
202
	 *             'recently-played' meas "The most recently played" instead of "Among the most recently played"
203
	 * @param int[] $genres Array of genre IDs
204
	 * @param int[] $artists Array of artist IDs
205
	 * @param int|null $fromYear Earliest release year to include
206
	 * @param int|null $toYear Latest release year to include
207
	 * @param string|null $favorite One of: 'track', 'album', 'artists', 'track_album_artist', null
208
	 * @param int $size Size of the playlist to generate, provided that there are enough matching tracks
209
	 * @param string $userId the name of the user
210
	 */
211
	public function generate(
212
			?string $history, bool $historyStrict, array $genres, array $artists,
213
			?int $fromYear, ?int $toYear, ?string $favorite, int $size, string $userId) : Playlist {
214
215
		$now = new \DateTime();
216
		$nowStr = $now->format(PlaylistMapper::SQL_DATE_FORMAT);
217
218
		$playlist = new Playlist();
219
		$playlist->setCreated($nowStr);
220
		$playlist->setUpdated($nowStr);
221
		$playlist->setName('Generated ' . $nowStr);
222
		$playlist->setUserId($userId);
223
224
		list('sortBy' => $sortBy, 'invert' => $invertSort) = self::sortRulesForHistory($history);
225
		$limit = ($sortBy === SortBy::None) ? null : ($historyStrict ? $size : $size * 4);
226
227
		$favoriteMask = self::favoriteMask($favorite);
228
229
		$tracks = $this->trackMapper->findAllByCriteria($genres, $artists, $fromYear, $toYear, $favoriteMask, $sortBy, $invertSort, $userId, $limit);
230
231
		if ($sortBy !== SortBy::None && !$historyStrict) {
232
			// When generating by non-strict history, use a pool of tracks at maximum twice the size of final list.
233
			// However, don't use more than half of the matching tracks unless that is required to satisfy the required list size.
234
			$poolSize = (int)max($size, \count($tracks) / 2);
235
			$tracks = \array_slice($tracks, 0, $poolSize);
236
		}
237
238
		// Pick the final random set of tracks
239
		$tracks = Random::pickItems($tracks, $size);
240
241
		$playlist->setTrackIdsFromArray(ArrayUtil::extractIds($tracks));
242
243
		return $playlist;
244
	}
245
246
	private static function sortRulesForHistory(?string $history) : array {
247
		switch ($history) {
248
			case 'recently-played':
249
				return ['sortBy' => SortBy::LastPlayed, 'invert' => false];
250
			case 'not-recently-played':
251
				return ['sortBy' => SortBy::LastPlayed, 'invert' => true];
252
			case 'often-played':
253
				return ['sortBy' => SortBy::PlayCount, 'invert' => false];
254
			case 'rarely-played':
255
				return ['sortBy' => SortBy::PlayCount, 'invert' => true];
256
			case 'recently-added':
257
				return ['sortBy' => SortBy::Newest, 'invert' => false];
258
			case 'not-recently-added':
259
				return ['sortBy' => SortBy::Newest, 'invert' => true];
260
			default:
261
				return ['sortBy' => SortBy::None, 'invert' => false];
262
		}
263
	}
264
265
	private static function favoriteMask(?string $mode) : ?int {
266
		switch ($mode) {
267
			case 'track':				return TrackMapper::FAVORITE_TRACK;
268
			case 'album':				return TrackMapper::FAVORITE_ALBUM;
269
			case 'artist':				return TrackMapper::FAVORITE_ARTIST;
270
			case 'track_album_artist':	return TrackMapper::FAVORITE_TRACK | TrackMapper::FAVORITE_ALBUM | TrackMapper::FAVORITE_ARTIST;
271
			default:					return null;
272
		}
273
	}
274
}
275