Passed
Push — master ( 87f5e7...2a0488 )
by Pauli
01:54
created

ArtistBusinessLayer::updateCovers()   B

Complexity

Conditions 6
Paths 10

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 34
rs 8.9777
c 0
b 0
f 0
cc 6
nc 10
nop 3
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 Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2021
13
 */
14
15
namespace OCA\Music\BusinessLayer;
16
17
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
18
use OCA\Music\AppFramework\Core\Logger;
19
20
use OCA\Music\Db\Artist;
21
use OCA\Music\Db\ArtistMapper;
22
use OCA\Music\Db\MatchMode;
23
use OCA\Music\Db\SortBy;
24
25
use OCA\Music\Utility\Util;
26
27
use OCP\IL10N;
28
use OCP\Files\File;
29
30
/**
31
 * Base class functions with the actually used inherited types to help IDE and Scrutinizer:
32
 * @method Artist find(int $trackId, string $userId)
33
 * @method Artist[] findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null)
34
 * @method Artist[] findAllByName(?string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null)
35
 * @method Artist[] findById(int[] $ids, string $userId=null, bool $preserveOrder=false)
36
 * @phpstan-extends BusinessLayer<Artist>
37
 */
38
class ArtistBusinessLayer extends BusinessLayer {
39
	protected $mapper; // eclipse the definition from the base class, to help IDE and Scrutinizer to know the actual type
40
	private $logger;
41
42
	private const FORBIDDEN_CHARS_IN_FILE_NAME = '<>:"/\|?*'; // chars forbidden in Windows, on Linux only '/' is technically forbidden
43
44
	public function __construct(ArtistMapper $artistMapper, Logger $logger) {
45
		parent::__construct($artistMapper);
46
		$this->mapper = $artistMapper;
47
		$this->logger = $logger;
48
	}
49
50
	/**
51
	 * Finds all artists who have at least one album
52
	 * @param string $userId the name of the user
53
	 * @param integer $sortBy sort order of the result set
54
	 * @return Artist[] artists
55
	 */
56
	public function findAllHavingAlbums(string $userId, int $sortBy=SortBy::None) : array {
57
		return $this->mapper->findAllHavingAlbums($userId, $sortBy);
58
	}
59
60
	/**
61
	 * Returns all artists filtered by genre
62
	 * @param int $genreId the genre to include
63
	 * @param string $userId the name of the user
64
	 * @param int|null $limit
65
	 * @param int|null $offset
66
	 * @return Artist[] artists
67
	 */
68
	public function findAllByGenre(int $genreId, string $userId, ?int $limit=null, ?int $offset=null) : array {
69
		return $this->mapper->findAllByGenre($genreId, $userId, $limit, $offset);
70
	}
71
72
	/**
73
	 * Find most frequently played artists, judged by the total play count of the contained tracks
74
	 * @return Artist[]
75
	 */
76
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
77
		$countsPerArtist = $this->mapper->getArtistTracksPlayCount($userId, $limit, $offset);
78
		$ids = \array_keys($countsPerArtist);
79
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
80
	}
81
82
	/**
83
	 * Find most recently played artists
84
	 * @return Artist[]
85
	 */
86
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
87
		$playTimePerArtist = $this->mapper->getLatestArtistPlayTimes($userId, $limit, $offset);
88
		$ids = \array_keys($playTimePerArtist);
89
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
90
	}
91
92
	/**
93
	 * Find least recently played artists
94
	 * @return Artist[]
95
	 */
96
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
97
		$playTimePerArtist = $this->mapper->getFurthestArtistPlayTimes($userId, $limit, $offset);
98
		$ids = \array_keys($playTimePerArtist);
99
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
100
	}
101
102
	/**
103
	 * Adds an artist if it does not exist already or updates an existing artist
104
	 * @param string|null $name the name of the artist
105
	 * @param string $userId the name of the user
106
	 * @return Artist The added/updated artist
107
	 */
108
	public function addOrUpdateArtist(?string $name, string $userId) : Artist {
109
		$artist = new Artist();
110
		$artist->setName(Util::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
111
		$artist->setUserId($userId);
112
		$artist->setHash(\hash('md5', \mb_strtolower($name ?? '')));
113
		return $this->mapper->updateOrInsert($artist);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->mapper->updateOrInsert($artist) returns the type OCA\Music\Db\Entity which includes types incompatible with the type-hinted return OCA\Music\Db\Artist.
Loading history...
114
	}
115
116
	/**
117
	 * Use the given file as cover art for an artist if there exists an artist
118
	 * with name matching the file name.
119
	 * @param File $imageFile
120
	 * @param string $userId
121
	 * @return int[] IDs of the modified artists; usually there should be 0 or 1 of these but
122
	 *					in some special occasions there could be more
123
	 */
124
	public function updateCover(File $imageFile, string $userId, IL10N $l10n) : array {
125
		$name = \pathinfo($imageFile->getName(), PATHINFO_FILENAME);
126
		\assert(\is_string($name)); // for scrutinizer
127
128
		$matches = $this->findAllByNameMatchingFilename($name, $userId, $l10n);
129
130
		$artistIds = [];
131
		foreach ($matches as $artist) {
132
			$artist->setCoverFileId($imageFile->getId());
133
			$this->mapper->update($artist);
134
			$artistIds[] = $artist->getId();
135
		}
136
137
		return $artistIds;
138
	}
139
140
	/**
141
	 * Match the given files by file name to the artist names. If there is a matching
142
	 * artist with no cover image already set, the matched file is set to be used as
143
	 * cover for this artist.
144
	 * @param File[] $imageFiles
145
	 * @param string $userId
146
	 * @return bool true if any artist covers were updated; false otherwise
147
	 */
148
	public function updateCovers(array $imageFiles, string $userId, IL10N $l10n) : bool {
149
		$updated = false;
150
151
		// Construct a lookup table for the images as there may potentially be
152
		// a huge amount of them. Any of the characters forbidden in Windows file names
153
		// may be replaced with an underscore, which is taken into account when building
154
		// the LUT.
155
		$replacedChars = \str_split(self::FORBIDDEN_CHARS_IN_FILE_NAME);
156
		\assert(\is_array($replacedChars)); // for scrutinizer
157
		$imageLut = [];
158
		foreach ($imageFiles as $imageFile) {
159
			$imageName = \pathinfo($imageFile->getName(), PATHINFO_FILENAME);
160
			$lookupName = \str_replace($replacedChars, '_', $imageName);
161
			$imageLut[$lookupName][] = ['name' => $imageName, 'file' => $imageFile];
162
		}
163
164
		$artists = $this->findAll($userId);
165
166
		foreach ($artists as $artist) {
167
			if ($artist->getCoverFileId() === null) {
168
				$artistLookupName = \str_replace($replacedChars, '_', $artist->getNameString($l10n));
169
				$lutEntries = $imageLut[$artistLookupName] ?? [];
170
				foreach ($lutEntries as $lutEntry) {
171
					if (self::filenameMatchesArtist($lutEntry['name'], $artist, $l10n)) {
172
						$artist->setCoverFileId($lutEntry['file']->getId());
173
						$this->mapper->update($artist);
174
						$updated = true;
175
						break;
176
					}
177
				}
178
			}
179
		}
180
181
		return $updated;
182
	}
183
184
	/**
185
	 * removes the given cover art files from artists
186
	 * @param integer[] $coverFileIds the file IDs of the cover images
187
	 * @param string[]|null $userIds the users whose music library is targeted; all users are targeted if omitted
188
	 * @return Artist[] artists which got modified, empty array if none
189
	 */
190
	public function removeCovers(array $coverFileIds, ?array $userIds=null) : array {
191
		return $this->mapper->removeCovers($coverFileIds, $userIds);
192
	}
193
194
	/**
195
	 * Find artists by name so that the characters forbidden on Windows file system are allowed to be
196
	 * replaced with underscores. In Linux, '/' would be the only truly forbidden character in paths
197
	 * but using characters forbidden in Windows might cause difficulties with interoperability.
198
	 * Support also finding by the localized "Unknown artist" string.
199
	 */
200
	private function findAllByNameMatchingFilename(string $name, string $userId, IL10N $l10n) : array {
201
		// we want to make '_' match any forbidden character on Linux or Windows but '%' in the
202
		// search pattern should not be handled as a wildcard but as literal
203
		$name = \str_replace('%', '\%', $name);
204
205
		$potentialMatches = $this->findAllByName($name, $userId, MatchMode::Wildcards);
206
207
		$matches = \array_filter($potentialMatches, function(Artist $artist) use ($name) : bool {
208
			return self::filenameMatchesArtist($name, $artist, $l10n);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $l10n seems to be never defined.
Loading history...
209
		});
210
211
		if ($name == Artist::unknownNameString($l10n)) {
212
			$matches = \array_merge($matches, $this->findAllByName(null, $userId));
213
		}
214
215
		return $matches;
216
	}
217
218
	/**
219
	 * Check if the given file name matches the given artist. The file name should be given without the extension.
220
	 */
221
	private static function filenameMatchesArtist(string $filename, Artist $artist, IL10N $l10n) : bool {
222
		$length = \strlen($filename);
223
		$artistName = $artist->getNameString($l10n);
224
		if ($length !== \strlen($artistName)) {
225
			return false;
226
		} elseif ($filename == $artistName) {
227
			return true; // exact match
228
		} else {
229
			// iterate over all the bytes and require that all the other bytes are equal but
230
			// underscores are allowed to match any forbidden filesystem chracter
231
			$matchedChars = self::FORBIDDEN_CHARS_IN_FILE_NAME . '_';
232
			for ($i = 0; $i < $length; ++$i) {
233
				if ($filename[$i] === '_') {
234
					if (\strpos($matchedChars, $artistName[$i]) === false) {
235
						return false;
236
					}
237
				} elseif ($filename[$i] !== $artistName[$i]) {
238
					return false;
239
				}
240
			}
241
			return true;
242
		}
243
	}
244
}
245