rootFolder = $rootFolder; $this->userSession = $userSession; } /** * Starts scanning the user's configured music directory for audio files. * * @return void */ public function scanMusicFiles(): void { $user = $this->userSession->getUser(); if ($user === null) { $this->logger->warning('Music scan aborted: no user session.'); return; } $this->scanUserByUID($user->getUID()); } /** * Scans the music directory for a specific user by UID. * * @param string $uid * @return void */ public function scanUserByUID(string $uid): void { try { $this->db->beginTransaction(); $userFolder = $this->rootFolder->getUserFolder($uid); $relativePath = $this->appConfig->getValueString(Application::APP_ID, 'music_folder_path_' . $uid, 'Music'); /** @var Folder $musicFolder */ $musicFolder = $userFolder->get($relativePath); if (!($musicFolder instanceof Folder)) { $this->logger->warning("Configured music path '$relativePath' for user $uid is not a folder."); return; } $this->logger->info("Starting music scan for user '$uid' in folder '$relativePath'"); $this->traverseFolder($musicFolder, $uid); $this->db->commit(); } catch (NotFoundException $e) { $this->logger->error("Could not find music folder for user $uid: " . $e->getMessage()); } catch (\Throwable $e) { $this->db->rollBack(); $this->logger->error('Scan failed: ' . $e->getMessage()); } } /** * Recursively traverses a folder and processes audio files. * * @param Folder $folder * @param string $uid * @return void */ private function traverseFolder(Folder $folder, string $uid): void { $this->logger->info('Scanning folder: ' . $folder->getPath()); foreach ($folder->getDirectoryListing() as $node) { if ($node instanceof File) { $mimeType = $node->getMimeType(); if (str_starts_with($mimeType, 'audio/')) { $this->logger->info('Found audio file: ' . $node->getPath() . " (MIME: $mimeType)"); $this->processAudioFile($node, $uid); } } elseif ($node instanceof Folder) { $this->traverseFolder($node, $uid); } } } /** * Downloads an audio file, reads metadata, and processes it. * * @param File $file * @param string $uid * @return void */ private function processAudioFile(File $file, string $uid): void { $this->logger->info('Processing audio file: ' . $file->getPath()); $tempPath = tempnam(sys_get_temp_dir(), 'jukebox_'); if ($tempPath === false) { $this->logger->error('Could not create temporary file for audio processing.'); return; } $stream = $file->fopen('r'); $handle = fopen($tempPath, 'w'); if ($stream === false || $handle === false) { $this->logger->error('Failed to open file stream or temp handle.'); return; } stream_copy_to_stream($stream, $handle); fclose($stream); fclose($handle); $getID3 = new getID3(); $info = $getID3->analyze($tempPath); unlink($tempPath); $this->saveMetadataToDatabase($uid, $file, $info); } /** * Placeholder method to save music metadata. * * @param string $userId * @param File $fileId * @param array $info * @return void */ private function saveMetadataToDatabase(string $userId, File $file, array $info): void { try { $path = $file->getPath(); $mtime = $file->getMTime(); $title = $info['tags']['id3v2']['title'][0] ?? $file->getName(); $trackArtist = $info['tags']['id3v2']['artist'][0] ?? ''; $albumArtist = $info['tags']['id3v2']['band'][0] ?? $info['tags']['id3v2']['album_artist'][0] ?? $info['tags']['quicktime']['album_artist'][0] ?? $info['tags']['asf']['WM/AlbumArtist'][0] ?? ''; $album = $info['tags']['id3v2']['album'][0] ?? ''; // Check for existing $existing = $this->musicMapper->findByUserIdAndPath($userId, $path); $media = $existing ?? new Track(); $media->setUserId($userId); $media->setPath($path); $media->setMtime($mtime); $media->setTitle($title); $media->setArtist($trackArtist); $media->setAlbumArtist($albumArtist); $media->setAlbum($album); $media->setTrackNumber($info['tags']['id3v2']['track_number'][0] ?? null); $media->setDuration((int)($info['playtime_seconds'] ?? 0)); $media->setGenre($info['tags']['id3v2']['genre'][0] ?? null); $media->setYear((int)($info['tags']['id3v2']['year'][0] ?? 0)); $media->setBitrate((int)($info['audio']['bitrate'] ?? 0) / 1000); $media->setCodec($info['audio']['dataformat'] ?? null); if (!empty($info['comments']['picture'][0]['data'])) { $media->setAlbumArt($info['comments']['picture'][0]['data']); } $sanitizedInfo = $this->sanitizeForJson($info); $rawData = json_encode($sanitizedInfo); if ($rawData !== false) { $media->setRawData($rawData); } else { $this->logger->warning("Failed to encode ID3 data for file '{$file->getPath()}'"); if (json_last_error() !== JSON_ERROR_NONE) { $this->logger->warning('JSON encode error: ' . json_last_error_msg()); } } if ($existing) { $this->musicMapper->update($media); } else { $this->musicMapper->insert($media); } $this->logger->info("Saved metadata for '$path'"); } catch (\Throwable $e) { $this->logger->error("Failed to save metadata for file '{$file->getPath()}': " . $e->getMessage()); } } private function sanitizeForJson(mixed $data, int $depth = 0): mixed { if ($depth > 30) { return '**depth limit exceeded**'; } if (is_resource($data)) { return '**resource**'; } if (is_object($data)) { if (method_exists($data, '__toString')) { return (string)$data; } return '**object**'; } if (is_array($data)) { $sanitized = []; foreach ($data as $key => $value) { if ( $key === 'data' && is_string($value) && isset($data['picturetype']) // heuristic for image ) { // $sanitized[$key] = base64_encode($value); $sanitized[$key] = '**binary data**'; // avoid large base64 strings in JSON } else { $sanitized[$key] = $this->sanitizeForJson($value, $depth + 1); } } return $sanitized; } if (is_string($data)) { // Convert broken encodings to valid UTF-8 using iconv with translit if (!mb_check_encoding($data, 'UTF-8')) { $converted = @iconv('ISO-8859-1', 'UTF-8//IGNORE', $data); return $converted !== false ? $converted : '**invalid string**'; } } return $data; } }