From aed9d87c5c2b74a3e927b53243168a2fa71199dc Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Mon, 10 Nov 2025 05:06:26 +0100 Subject: [PATCH] jellyfin music :) --- app/Controllers/AdminController.php | 2 +- app/Controllers/MusicController.php | 108 +++- app/Models/MusicAlbum.php | 235 +++++++++ app/Models/MusicArtist.php | 197 ++++++++ app/Models/MusicTrack.php | 327 ++++++++++++ app/Services/JellyfinSyncService.php | 311 +++++++++++- resources/views/admin/index.twig | 5 + resources/views/music/index.twig | 713 ++++++++++++++++++++++++--- resources/views/music/show.twig | 188 ++++++- sync-runner.php | 2 +- 10 files changed, 1994 insertions(+), 94 deletions(-) create mode 100644 app/Models/MusicAlbum.php create mode 100644 app/Models/MusicArtist.php create mode 100644 app/Models/MusicTrack.php diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 87b2510..f46280d 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -472,7 +472,7 @@ class AdminController extends AdminBaseController // Validate sync type based on source type if ($source['name'] === 'jellyfin') { - $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'cleanup']; + $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'music', 'cleanup']; if (!in_array($syncType, $validSyncTypes)) { return $this->json($response, [ 'success' => false, diff --git a/app/Controllers/MusicController.php b/app/Controllers/MusicController.php index 09e7efe..ee7876e 100644 --- a/app/Controllers/MusicController.php +++ b/app/Controllers/MusicController.php @@ -27,21 +27,43 @@ class MusicController extends Controller // Get search parameters $search = trim($queryParams['search'] ?? ''); + // Get filter parameters + $genres = $queryParams['genres'] ?? []; + if (!is_array($genres)) { + $genres = [$genres]; + } + $genres = array_filter($genres); + + $artists = $queryParams['artists'] ?? []; + if (!is_array($artists)) { + $artists = [$artists]; + } + $artists = array_filter($artists); + // Get view mode $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers - // For now, return empty arrays since Music isn't implemented yet - $music = []; - $totalCount = 0; + // Get sort parameter + $sort = $queryParams['sort'] ?? 'title_asc'; + + // Get albums with pagination, filters, and sorting + $albums = \App\Models\MusicAlbum::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $artists, $sort); + + // Get total count for pagination + $totalCount = \App\Models\MusicAlbum::getTotalCount($this->pdo, $search, $genres, $artists); + + // Get available filter options + $availableGenres = \App\Models\MusicAlbum::getAvailableGenres($this->pdo); + $availableArtists = \App\Models\MusicAlbum::getAvailableArtists($this->pdo); // Calculate pagination info - $totalPages = 0; - $hasNextPage = false; - $hasPrevPage = false; + $totalPages = ceil($totalCount / $perPage); + $hasNextPage = $page < $totalPages; + $hasPrevPage = $page > 1; return $this->view->render($response, 'music/index.twig', [ 'title' => 'Music', - 'music' => $music, + 'albums' => $albums, 'pagination' => [ 'current_page' => $page, 'per_page' => $perPage, @@ -54,19 +76,79 @@ class MusicController extends Controller ], 'search' => $search, 'view_mode' => $viewMode, - 'view_modes' => ['grid', 'list', 'covers'] + 'view_modes' => ['grid', 'list', 'covers'], + 'filters' => [ + 'genres' => $genres, + 'artists' => $artists + ], + 'available_filters' => [ + 'genres' => $availableGenres, + 'artists' => $availableArtists + ], + 'sort' => $sort, + 'sort_options' => [ + 'title_asc' => 'Title (A-Z)', + 'title_desc' => 'Title (Z-A)', + 'artist_asc' => 'Artist (A-Z)', + 'artist_desc' => 'Artist (Z-A)', + 'release_desc' => 'Release Date (Newest First)', + 'release_asc' => 'Release Date (Oldest First)', + 'added_desc' => 'Recently Added', + 'added_asc' => 'Oldest Added' + ] ]); } public function show(Request $request, Response $response, $args) { - $musicId = (int) $args['id']; + $albumId = (int) $args['id']; + + // Get album details + $stmt = $this->pdo->prepare(" + SELECT ma.*, s.display_name as source_name + FROM music_albums ma + JOIN sources s ON ma.source_id = s.id + WHERE ma.id = :id + "); + $stmt->execute(['id' => $albumId]); + $album = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$album) { + return $response->withStatus(404); + } + + // Decode metadata for display + $metadata = json_decode($album['metadata'], true); + + // Get tracks for this album + $stmt = $this->pdo->prepare(" + SELECT mt.* + FROM music_tracks mt + WHERE mt.album_id = :album_id + ORDER BY mt.track_number ASC, mt.title ASC + "); + $stmt->execute(['album_id' => $albumId]); + $tracks = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Get artist information + $artist = null; + if ($album['artist_id']) { + $stmt = $this->pdo->prepare(" + SELECT ma.*, s.display_name as source_name + FROM music_artists ma + JOIN sources s ON ma.source_id = s.id + WHERE ma.id = :artist_id + "); + $stmt->execute(['artist_id' => $album['artist_id']]); + $artist = $stmt->fetch(\PDO::FETCH_ASSOC); + } - // For now, return a placeholder since Music isn't implemented yet return $this->view->render($response, 'music/show.twig', [ - 'title' => 'Music Details', - 'music' => ['id' => $musicId, 'title' => 'Coming Soon'], - 'message' => 'Music details page is not yet implemented.' + 'title' => $album['title'], + 'album' => $album, + 'tracks' => $tracks, + 'artist' => $artist, + 'metadata' => $metadata ]); } } diff --git a/app/Models/MusicAlbum.php b/app/Models/MusicAlbum.php new file mode 100644 index 0000000..36ca40d --- /dev/null +++ b/app/Models/MusicAlbum.php @@ -0,0 +1,235 @@ + 'date', + 'track_count' => 'int', + 'total_duration_seconds' => 'int', + 'is_favorite' => 'bool' + ]; + + public function source() + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } + + /** + * Get the artist for this album + */ + public function artist() + { + $stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE id = :artist_id"); + $stmt->execute(['artist_id' => $this->artist_id]); + $artistData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $artistData ? new MusicArtist($this->pdo, $artistData) : null; + } + + /** + * Get all tracks for this album + */ + public function tracks() + { + $stmt = $this->pdo->prepare(" + SELECT * FROM music_tracks + WHERE album_id = :album_id + ORDER BY track_number ASC, title ASC + "); + $stmt->execute(['album_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Toggle favorite status + */ + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + /** + * Get total count of albums with optional filters + */ + public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $artists = []): int + { + $where = []; + $params = []; + + $sql = "SELECT COUNT(*) as count FROM music_albums ma JOIN sources s ON ma.source_id = s.id"; + + if (!empty($search)) { + $where[] = "(ma.title LIKE :search OR ma.artist_name LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_$i"; + $genreConditions[] = "ma.genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($artists)) { + $artistConditions = []; + foreach ($artists as $i => $artist) { + $param = ":artist_$i"; + $artistConditions[] = "ma.artist_name LIKE $param"; + $params[$param] = "%$artist%"; + } + $where[] = "(" . implode(' OR ', $artistConditions) . ")"; + } + + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return (int)$stmt->fetchColumn(); + } + + /** + * Get paginated albums with optional filters + */ + public static function getAllWithPagination( + \PDO $pdo, + int $page = 1, + int $perPage = 20, + string $search = '', + array $genres = [], + array $artists = [], + string $sort = 'title_asc' + ): array { + $offset = ($page - 1) * $perPage; + $where = []; + $params = []; + + if (!empty($search)) { + $where[] = "(title LIKE :search OR artist_name LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre$i"; + $genreConditions[] = "genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($artists)) { + $artistConditions = []; + foreach ($artists as $i => $artist) { + $param = ":artist$i"; + $artistConditions[] = "artist_name LIKE $param"; + $params[$param] = "%$artist%"; + } + $where[] = "(" . implode(' OR ', $artistConditions) . ")"; + } + + // Determine sort order + $orderBy = 'title ASC'; + switch ($sort) { + case 'title_desc': + $orderBy = 'title DESC'; + break; + case 'artist_asc': + $orderBy = 'artist_name ASC'; + break; + case 'artist_desc': + $orderBy = 'artist_name DESC'; + break; + case 'release_asc': + $orderBy = 'release_date ASC'; + break; + case 'release_desc': + $orderBy = 'release_date DESC'; + break; + } + + $sql = "SELECT ma.*, s.display_name as source_name FROM music_albums ma JOIN sources s ON ma.source_id = s.id"; + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + $sql .= " ORDER BY $orderBy LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + + // Bind parameters + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + $stmt->execute(); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all unique genres from albums + */ + public static function getAvailableGenres(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT genre FROM music_albums WHERE genre IS NOT NULL AND genre != '' ORDER BY genre"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get all unique artists from albums + */ + public static function getAvailableArtists(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT artist_name FROM music_albums WHERE artist_name IS NOT NULL AND artist_name != '' ORDER BY artist_name"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get album statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_albums, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_albums, + SUM(track_count) as total_tracks, + SUM(total_duration_seconds) as total_duration + FROM music_albums + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } +} diff --git a/app/Models/MusicArtist.php b/app/Models/MusicArtist.php new file mode 100644 index 0000000..aee021a --- /dev/null +++ b/app/Models/MusicArtist.php @@ -0,0 +1,197 @@ + 'date', + 'is_favorite' => 'bool' + ]; + + public function source() + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } + + /** + * Get all albums by this artist + */ + public function albums() + { + $stmt = $this->pdo->prepare(" + SELECT * FROM music_albums + WHERE artist_id = :artist_id + ORDER BY release_date DESC, title ASC + "); + $stmt->execute(['artist_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all tracks by this artist + */ + public function tracks() + { + $stmt = $this->pdo->prepare(" + SELECT * FROM music_tracks + WHERE artist_id = :artist_id + ORDER BY album_name ASC, track_number ASC, title ASC + "); + $stmt->execute(['artist_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Toggle favorite status + */ + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + /** + * Get total count of artists with optional filters + */ + public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = []): int + { + $where = []; + $params = []; + + $sql = "SELECT COUNT(*) as count FROM music_artists ma JOIN sources s ON ma.source_id = s.id"; + + if (!empty($search)) { + $where[] = "(ma.name LIKE :search OR ma.biography LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_$i"; + $genreConditions[] = "ma.genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return (int)$stmt->fetchColumn(); + } + + /** + * Get paginated artists with optional filters + */ + public static function getAllWithPagination( + \PDO $pdo, + int $page = 1, + int $perPage = 20, + string $search = '', + array $genres = [], + string $sort = 'name_asc' + ): array { + $offset = ($page - 1) * $perPage; + $where = []; + $params = []; + + if (!empty($search)) { + $where[] = "(name LIKE :search OR biography LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre$i"; + $genreConditions[] = "genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + // Determine sort order + $orderBy = 'name ASC'; + switch ($sort) { + case 'name_desc': + $orderBy = 'name DESC'; + break; + case 'formed_asc': + $orderBy = 'formed_date ASC'; + break; + case 'formed_desc': + $orderBy = 'formed_date DESC'; + break; + } + + $sql = "SELECT ma.*, s.display_name as source_name FROM music_artists ma JOIN sources s ON ma.source_id = s.id"; + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + $sql .= " ORDER BY $orderBy LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + + // Bind parameters + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + $stmt->execute(); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all unique genres from artists + */ + public static function getAvailableGenres(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT genre FROM music_artists WHERE genre IS NOT NULL AND genre != '' ORDER BY genre"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get artist statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_artists, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_artists, + COUNT(DISTINCT genre) as total_genres + FROM music_artists + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } +} diff --git a/app/Models/MusicTrack.php b/app/Models/MusicTrack.php new file mode 100644 index 0000000..875eb40 --- /dev/null +++ b/app/Models/MusicTrack.php @@ -0,0 +1,327 @@ + 'int', + 'duration_seconds' => 'int', + 'play_count' => 'int', + 'is_favorite' => 'bool', + 'release_date' => 'date', + 'last_played_at' => 'datetime' + ]; + + public function source() + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } + + /** + * Get the artist for this track + */ + public function artist() + { + $stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE id = :artist_id"); + $stmt->execute(['artist_id' => $this->artist_id]); + $artistData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $artistData ? new MusicArtist($this->pdo, $artistData) : null; + } + + /** + * Get the album for this track + */ + public function album() + { + if (!$this->album_id) { + return null; + } + + $stmt = $this->pdo->prepare("SELECT * FROM music_albums WHERE id = :album_id"); + $stmt->execute(['album_id' => $this->album_id]); + $albumData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $albumData ? new MusicAlbum($this->pdo, $albumData) : null; + } + + /** + * Toggle favorite status + */ + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + /** + * Increment play count and update last played time + */ + public function markAsPlayed(): bool + { + return $this->update($this->id, [ + 'play_count' => $this->play_count + 1, + 'last_played_at' => date('Y-m-d H:i:s') + ]); + } + + /** + * Get total count of tracks with optional filters + */ + public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $artists = [], array $albums = []): int + { + $where = []; + $params = []; + + $sql = "SELECT COUNT(*) as count FROM music_tracks mt JOIN sources s ON mt.source_id = s.id"; + + if (!empty($search)) { + $where[] = "(mt.title LIKE :search OR mt.artist_name LIKE :search OR mt.album_name LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre_$i"; + $genreConditions[] = "mt.genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($artists)) { + $artistConditions = []; + foreach ($artists as $i => $artist) { + $param = ":artist_$i"; + $artistConditions[] = "mt.artist_name LIKE $param"; + $params[$param] = "%$artist%"; + } + $where[] = "(" . implode(' OR ', $artistConditions) . ")"; + } + + if (!empty($albums)) { + $albumConditions = []; + foreach ($albums as $i => $album) { + $param = ":album_$i"; + $albumConditions[] = "mt.album_name LIKE $param"; + $params[$param] = "%$album%"; + } + $where[] = "(" . implode(' OR ', $albumConditions) . ")"; + } + + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return (int)$stmt->fetchColumn(); + } + + /** + * Get paginated tracks with optional filters + */ + public static function getAllWithPagination( + \PDO $pdo, + int $page = 1, + int $perPage = 20, + string $search = '', + array $genres = [], + array $artists = [], + array $albums = [], + string $sort = 'title_asc' + ): array { + $offset = ($page - 1) * $perPage; + $where = []; + $params = []; + + if (!empty($search)) { + $where[] = "(title LIKE :search OR artist_name LIKE :search OR album_name LIKE :search)"; + $params[':search'] = "%$search%"; + } + + if (!empty($genres)) { + $genreConditions = []; + foreach ($genres as $i => $genre) { + $param = ":genre$i"; + $genreConditions[] = "genre LIKE $param"; + $params[$param] = "%$genre%"; + } + $where[] = "(" . implode(' OR ', $genreConditions) . ")"; + } + + if (!empty($artists)) { + $artistConditions = []; + foreach ($artists as $i => $artist) { + $param = ":artist$i"; + $artistConditions[] = "artist_name LIKE $param"; + $params[$param] = "%$artist%"; + } + $where[] = "(" . implode(' OR ', $artistConditions) . ")"; + } + + if (!empty($albums)) { + $albumConditions = []; + foreach ($albums as $i => $album) { + $param = ":album$i"; + $albumConditions[] = "album_name LIKE $param"; + $params[$param] = "%$album%"; + } + $where[] = "(" . implode(' OR ', $albumConditions) . ")"; + } + + // Determine sort order + $orderBy = 'title ASC'; + switch ($sort) { + case 'title_desc': + $orderBy = 'title DESC'; + break; + case 'artist_asc': + $orderBy = 'artist_name ASC'; + break; + case 'artist_desc': + $orderBy = 'artist_name DESC'; + break; + case 'album_asc': + $orderBy = 'album_name ASC'; + break; + case 'album_desc': + $orderBy = 'album_name DESC'; + break; + case 'duration_asc': + $orderBy = 'duration_seconds ASC'; + break; + case 'duration_desc': + $orderBy = 'duration_seconds DESC'; + break; + case 'play_count_desc': + $orderBy = 'play_count DESC'; + break; + case 'last_played_desc': + $orderBy = 'last_played_at DESC NULLS LAST'; + break; + } + + $sql = "SELECT mt.*, s.display_name as source_name FROM music_tracks mt JOIN sources s ON mt.source_id = s.id"; + if (!empty($where)) { + $sql .= " WHERE " . implode(' AND ', $where); + } + $sql .= " ORDER BY $orderBy LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + + // Bind parameters + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + $stmt->execute(); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all unique genres from tracks + */ + public static function getAvailableGenres(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT genre FROM music_tracks WHERE genre IS NOT NULL AND genre != '' ORDER BY genre"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get all unique artists from tracks + */ + public static function getAvailableArtists(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT artist_name FROM music_tracks WHERE artist_name IS NOT NULL AND artist_name != '' ORDER BY artist_name"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get all unique albums from tracks + */ + public static function getAvailableAlbums(\PDO $pdo): array + { + $stmt = $pdo->query("SELECT DISTINCT album_name FROM music_tracks WHERE album_name IS NOT NULL AND album_name != '' ORDER BY album_name"); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get track statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_tracks, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_tracks, + SUM(play_count) as total_plays, + SUM(duration_seconds) as total_duration, + AVG(duration_seconds) as avg_duration + FROM music_tracks + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * Get recently played tracks + */ + public static function getRecentlyPlayed(\PDO $pdo, int $limit = 10): array + { + $stmt = $pdo->prepare(" + SELECT mt.*, s.display_name as source_name + FROM music_tracks mt + JOIN sources s ON mt.source_id = s.id + WHERE mt.last_played_at IS NOT NULL + ORDER BY mt.last_played_at DESC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get most played tracks + */ + public static function getMostPlayed(\PDO $pdo, int $limit = 10): array + { + $stmt = $pdo->prepare(" + SELECT mt.*, s.display_name as source_name + FROM music_tracks mt + JOIN sources s ON mt.source_id = s.id + ORDER BY mt.play_count DESC, mt.title ASC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } +} diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php index 524d95c..b58d849 100644 --- a/app/Services/JellyfinSyncService.php +++ b/app/Services/JellyfinSyncService.php @@ -5,6 +5,9 @@ namespace App\Services; use App\Models\Movie; use App\Models\TvShow; use App\Models\TvEpisode; +use App\Models\MusicArtist; +use App\Models\MusicAlbum; +use App\Models\MusicTrack; use GuzzleHttp\Client; use GuzzleHttp\Exception\RequestException; use Exception; @@ -94,8 +97,19 @@ class JellyfinSyncService extends BaseSyncService $this->logProgress('Skipping TV shows sync (sync type: ' . $syncType . ')'); } - // Sync music (artists, albums, tracks) - TODO: Implement when music models are created - // $this->syncMusic(); + // Sync music (artists, albums, tracks) if requested + if (in_array($syncType, ['all', 'music'])) { + try { + $this->syncMusic(); + } catch (Exception $e) { + $this->logProgress('Error syncing music: ' . $e->getMessage()); + if ($syncType === 'music') { + throw $e; + } + } + } else { + $this->logProgress('Skipping music sync (sync type: ' . $syncType . ')'); + } $this->logProgress("Processed {$this->processedCount} items"); } @@ -519,6 +533,299 @@ class JellyfinSyncService extends BaseSyncService $this->logProgress("--- Completed sync for episode: {$episodeName} ---"); } + private function syncMusic(): void + { + try { + $this->logProgress('=== Starting Music Sync ==='); + + // Sync artists first + $this->logProgress('Fetching music artists from Jellyfin...'); + $artists = $this->getJellyfinItems('MusicArtist'); + $artistCount = count($artists); + $this->logProgress("Found {$artistCount} artists in Jellyfin"); + + $processedArtists = 0; + $successfulArtists = 0; + $failedArtists = 0; + + foreach ($artists as $artistData) { + $processedArtists++; + $artistName = $artistData['Name'] ?? 'Unknown Artist'; + $this->logProgress("Processing artist {$processedArtists}/{$artistCount}: {$artistName}"); + + try { + $this->syncMusicArtist($artistData); + $successfulArtists++; + $this->logProgress("✓ Successfully synced artist: {$artistName}"); + } catch (Exception $e) { + $failedArtists++; + $this->logProgress("✗ Failed to sync artist {$artistName}: " . $e->getMessage()); + } + } + + $this->logProgress("Artists sync summary: {$successfulArtists} successful, {$failedArtists} failed"); + + // Sync albums + $this->logProgress('Fetching music albums from Jellyfin...'); + $albums = $this->getJellyfinItems('MusicAlbum'); + $albumCount = count($albums); + $this->logProgress("Found {$albumCount} albums in Jellyfin"); + + $processedAlbums = 0; + $successfulAlbums = 0; + $failedAlbums = 0; + + foreach ($albums as $albumData) { + $processedAlbums++; + $albumName = $albumData['Name'] ?? 'Unknown Album'; + $this->logProgress("Processing album {$processedAlbums}/{$albumCount}: {$albumName}"); + + try { + $this->syncMusicAlbum($albumData); + $successfulAlbums++; + $this->logProgress("✓ Successfully synced album: {$albumName}"); + } catch (Exception $e) { + $failedAlbums++; + $this->logProgress("✗ Failed to sync album {$albumName}: " . $e->getMessage()); + } + } + + $this->logProgress("Albums sync summary: {$successfulAlbums} successful, {$failedAlbums} failed"); + + // Sync tracks + $this->logProgress('Fetching music tracks from Jellyfin...'); + $tracks = $this->getJellyfinItems('Audio'); + $trackCount = count($tracks); + $this->logProgress("Found {$trackCount} tracks in Jellyfin"); + + $processedTracks = 0; + $successfulTracks = 0; + $failedTracks = 0; + + foreach ($tracks as $trackData) { + $processedTracks++; + $trackName = $trackData['Name'] ?? 'Unknown Track'; + $this->logProgress("Processing track {$processedTracks}/{$trackCount}: {$trackName}"); + + try { + $this->syncMusicTrack($trackData); + $successfulTracks++; + $this->logProgress("✓ Successfully synced track: {$trackName}"); + } catch (Exception $e) { + $failedTracks++; + $this->logProgress("✗ Failed to sync track {$trackName}: " . $e->getMessage()); + } + } + + $this->logProgress("Tracks sync summary: {$successfulTracks} successful, {$failedTracks} failed"); + $this->logProgress("=== Music Sync Completed ==="); + + } catch (Exception $e) { + $this->logProgress('CRITICAL ERROR in music sync: ' . $e->getMessage()); + $this->logProgress('Stack trace: ' . $e->getTraceAsString()); + throw $e; + } + } + + private function syncMusicArtist(array $artistData): void + { + $artistModel = new MusicArtist($this->pdo); + + // Check if artist already exists + $existingArtist = $artistModel->findAll([ + 'name' => $artistData['Name'], + 'source_id' => $this->source['id'] + ]); + + $artistDataForDb = [ + 'name' => $artistData['Name'], + 'biography' => $artistData['Overview'] ?? null, + 'formed_date' => isset($artistData['PremiereDate']) ? date('Y-m-d', strtotime($artistData['PremiereDate'])) : null, + 'genre' => isset($artistData['Genres'][0]) ? $artistData['Genres'][0] : null, + 'country' => null, // Jellyfin doesn't provide country info + 'image_url' => $this->getImageUrl($artistData['Id'], 'Primary'), + 'banner_url' => $this->getImageUrl($artistData['Id'], 'Backdrop'), + 'spotify_id' => $artistData['ProviderIds']['MusicBrainzArtist'] ?? null, + 'musicbrainz_id' => $artistData['ProviderIds']['MusicBrainzArtist'] ?? null, + 'source_id' => $this->source['id'], + 'metadata' => json_encode([ + 'jellyfin_id' => $artistData['Id'] + ]) + ]; + + if (empty($existingArtist)) { + $artistModel->create($artistDataForDb); + $this->newCount++; + } else { + $artistModel->update($existingArtist[0]['id'], $artistDataForDb); + $this->updatedCount++; + } + + $this->processedCount++; + } + + private function syncMusicAlbum(array $albumData): void + { + $albumModel = new MusicAlbum($this->pdo); + + // Get or create artist first + $artistId = null; + if (isset($albumData['AlbumArtists']) && !empty($albumData['AlbumArtists'])) { + $artistName = $albumData['AlbumArtists'][0]['Name']; + $artistId = $this->getOrCreateMusicArtist($artistName); + } + + // Check if album already exists + $existingAlbum = $albumModel->findAll([ + 'title' => $albumData['Name'], + 'artist_id' => $artistId, + 'source_id' => $this->source['id'] + ]); + + // Download cover image + $coverPath = $this->downloadPosterImage($albumData['Id'], $albumData['Name']); + if (!$coverPath) { + $coverPath = $this->getImageUrl($albumData['Id'], 'Primary'); + } + + $albumDataForDb = [ + 'title' => $albumData['Name'], + 'artist_name' => isset($albumData['AlbumArtists'][0]['Name']) ? $albumData['AlbumArtists'][0]['Name'] : 'Unknown Artist', + 'release_date' => isset($albumData['PremiereDate']) ? date('Y-m-d', strtotime($albumData['PremiereDate'])) : null, + 'genre' => isset($albumData['Genres'][0]) ? $albumData['Genres'][0] : null, + 'track_count' => 0, // Will be updated when tracks are synced + 'total_duration_seconds' => 0, // Will be updated when tracks are synced + 'cover_url' => $coverPath, + 'spotify_id' => $albumData['ProviderIds']['MusicBrainzAlbum'] ?? null, + 'musicbrainz_id' => $albumData['ProviderIds']['MusicBrainzAlbum'] ?? null, + 'artist_id' => $artistId, + 'source_id' => $this->source['id'], + 'metadata' => json_encode([ + 'jellyfin_id' => $albumData['Id'] + ]) + ]; + + if (empty($existingAlbum)) { + $albumModel->create($albumDataForDb); + $this->newCount++; + } else { + $albumModel->update($existingAlbum[0]['id'], $albumDataForDb); + $this->updatedCount++; + } + + $this->processedCount++; + } + + private function syncMusicTrack(array $trackData): void + { + $trackModel = new MusicTrack($this->pdo); + + // Get or create artist + $artistId = null; + if (isset($trackData['AlbumArtists']) && !empty($trackData['AlbumArtists'])) { + $artistName = $trackData['AlbumArtists'][0]['Name']; + $artistId = $this->getOrCreateMusicArtist($artistName); + } + + // Get or create album + $albumId = null; + if (isset($trackData['Album']) && !empty($trackData['Album'])) { + $albumId = $this->getOrCreateMusicAlbum($trackData['Album'], $artistId); + } + + // Check if track already exists + $existingTrack = $trackModel->findAll([ + 'title' => $trackData['Name'], + 'artist_id' => $artistId, + 'album_id' => $albumId, + 'source_id' => $this->source['id'] + ]); + + $durationSeconds = isset($trackData['RunTimeTicks']) ? intval($trackData['RunTimeTicks'] / 10000000) : null; + + $trackDataForDb = [ + 'title' => $trackData['Name'], + 'artist_name' => isset($trackData['AlbumArtists'][0]['Name']) ? $trackData['AlbumArtists'][0]['Name'] : 'Unknown Artist', + 'album_name' => $trackData['Album'] ?? null, + 'track_number' => $trackData['IndexNumber'] ?? null, + 'duration_seconds' => $durationSeconds, + 'genre' => isset($trackData['Genres'][0]) ? $trackData['Genres'][0] : null, + 'release_date' => isset($trackData['PremiereDate']) ? date('Y-m-d', strtotime($trackData['PremiereDate'])) : null, + 'play_count' => 0, + 'artist_id' => $artistId, + 'album_id' => $albumId, + 'source_id' => $this->source['id'], + 'metadata' => json_encode([ + 'jellyfin_id' => $trackData['Id'] + ]) + ]; + + if (empty($existingTrack)) { + $trackModel->create($trackDataForDb); + $this->newCount++; + } else { + $trackModel->update($existingTrack[0]['id'], $trackDataForDb); + $this->updatedCount++; + } + + $this->processedCount++; + } + + private function getOrCreateMusicArtist(string $artistName): ?int + { + $artistModel = new MusicArtist($this->pdo); + + // Check if artist exists + $existingArtist = $artistModel->findAll([ + 'name' => $artistName, + 'source_id' => $this->source['id'] + ]); + + if (!empty($existingArtist)) { + return $existingArtist[0]['id']; + } + + // Create new artist + $artistData = [ + 'name' => $artistName, + 'source_id' => $this->source['id'], + 'metadata' => json_encode([]) + ]; + + $artistId = $artistModel->create($artistData); + return $artistId; + } + + private function getOrCreateMusicAlbum(string $albumName, ?int $artistId): ?int + { + $albumModel = new MusicAlbum($this->pdo); + + // Check if album exists + $existingAlbum = $albumModel->findAll([ + 'title' => $albumName, + 'artist_id' => $artistId, + 'source_id' => $this->source['id'] + ]); + + if (!empty($existingAlbum)) { + return $existingAlbum[0]['id']; + } + + // Create new album + $albumData = [ + 'title' => $albumName, + 'artist_name' => 'Unknown Artist', // Will be updated when artist is found + 'track_count' => 0, + 'total_duration_seconds' => 0, + 'artist_id' => $artistId, + 'source_id' => $this->source['id'], + 'metadata' => json_encode([]) + ]; + + $albumId = $albumModel->create($albumData); + return $albumId; + } + private function getShowEpisodes(string $jellyfinShowId): array { $this->logProgress("--- Fetching episodes for show ID: {$jellyfinShowId} ---"); diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig index 1c8f7a2..788a269 100644 --- a/resources/views/admin/index.twig +++ b/resources/views/admin/index.twig @@ -67,6 +67,11 @@ data-source-id="{{ source.id }}"> TV Shows Only +
- - -
- {% for mode in view_modes %} - - {% endfor %} -
-
- - - -
- - + + + + +
+ +
+
+ {% for key, label in sort_options %} + + {{ label }} + {% if sort == key %} + + + + {% endif %} + + {% endfor %} +
{% endblock %} + +{% block item_list %} +{% if view_mode == 'list' and albums %} +
+ + {% set grouped_albums = {} %} + {% for album in albums %} + {% set first_letter = album.title|first|upper %} + {% if grouped_albums[first_letter] is defined %} + {% set grouped_albums = grouped_albums|merge({(first_letter): grouped_albums[first_letter]|merge([album])}) %} + {% else %} + {% set grouped_albums = grouped_albums|merge({(first_letter): [album]}) %} + {% endif %} + {% endfor %} + + {% for letter, letter_albums in grouped_albums|sort %} + + {% endfor %} +
+{% else %} +
+
+ No items to display +
+
+{% endif %} +{% endblock %} + +{% block sidebar %} +
+ +
+

Filters

+ + +
+ + + + + + {% if available_filters.genres %} +
+ + +
+ {% endif %} + + + {% if available_filters.artists %} +
+ + +
+ {% endif %} + + +
+ + + Clear All + +
+
+
+ + + {% if filters.genres or filters.artists or search %} +
+

Active Filters

+
+ {% if search %} +
+ Search: "{{ search }}" + + + + + +
+ {% endif %} + {% for genre in filters.genres %} +
+ Genre: {{ genre }} + + + + + +
+ {% endfor %} + {% for artist in filters.artists %} +
+ Artist: {{ artist }} + + + + + +
+ {% endfor %} +
+
+ {% endif %} + + +
+

Quick Stats

+
+
+ Total Albums + {{ pagination.total_items }} +
+
+ This Page + {{ albums|length }} +
+ {% if pagination.total_pages > 1 %} +
+ Page + {{ pagination.current_page }} of {{ pagination.total_pages }} +
+ {% endif %} +
+
+
+{% endblock %} + +{% block content %} + +
+ +
+

Music

+ {% if pagination.total_items > 0 %} +
+ {{ pagination.total_items }} albums + {% if search %} + matching "{{ search }}" + {% endif %} + {% if filters.genres or filters.artists %} + {% if filters.genres %} + {{ filters.genres|join(', ') }} + {% endif %} + {% if filters.artists %} + {{ filters.artists|join(', ') }} + {% endif %} + {% endif %} +
+ {% endif %} +
+ + {% if albums is empty %} +
+ + + +

+ {% if search or filters.genres or filters.artists %} + No albums found matching your criteria + {% else %} + No albums found + {% endif %} +

+

+ {% if search or filters.genres or filters.artists %} + Try adjusting your search terms or filters. + {% else %} + Start syncing your music libraries to see your albums here. + {% endif %} +

+ {% if search or filters.genres or filters.artists %} + + Clear filters + + {% endif %} +
+ {% else %} + + {% if view_mode == 'list' %} + +
+
    + {% for album in albums %} +
  • +
    +
    +
    + {% if album.cover_url %} + {{ album.title }} + {% else %} +
    + + + +
    + {% endif %} +
    +
    +

    + + {{ album.title }} + +

    +
    + {{ album.artist_name }} + {% if album.release_date %} + {{ album.release_date|date('Y') }} + {% endif %} + {% if album.track_count %} + {{ album.track_count }} tracks + {% endif %} + {{ album.source_name }} +
    +
    +
    +
    + {% if album.is_favorite %} + + Favorite + + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+ + {% elseif view_mode == 'covers' %} + +
+ {% for album in albums %} +
+ {% if album.cover_url %} +
+ {{ album.title }} + +
+
+ {% if album.track_count %} +
+ + + + {{ album.track_count }} tracks +
+ {% endif %} + {% if album.is_favorite %} +
+ + + + Favorite +
+ {% endif %} +
+
+
+ {% else %} +
+ + + +
+ {% endif %} +
+
+ + {{ album.title }} + +
+

{{ album.artist_name }}

+ {% if album.release_date %} +

{{ album.release_date|date('Y') }}

+ {% endif %} + {% if album.genre %} +
+ {% for genre in album.genre|split(',')|slice(0, 2) %} + {{ genre|trim }} + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ + {% else %} + +
+ {% for album in albums %} +
+
+
+
+ {% if album.cover_url %} + {{ album.title }} + {% else %} +
+ + + +
+ {% endif %} +
+
+
+ + {{ album.title }} + +
+
+ {{ album.artist_name }} + {% if album.release_date %} + {{ album.release_date|date('Y') }} + {% endif %} +
+ {% if album.source_name %} +

+ {{ album.source_name }} +

+ {% endif %} +
+
+ {% if album.track_count %} +
+

{{ album.track_count }} tracks

+
+ {% endif %} +
+ {% if album.total_duration_seconds %} + {{ (album.total_duration_seconds / 60)|round(0) }} min + {% endif %} +
+ {% if album.is_favorite %} + + Favorite + + {% endif %} +
+
+
+
+ {% endfor %} +
+ {% endif %} + + + {% if pagination.total_pages > 1 %} +
+
+
+ Showing {{ (pagination.current_page - 1) * pagination.per_page + 1 }} to {{ min(pagination.current_page * pagination.per_page, pagination.total_items) }} of {{ pagination.total_items }} albums +
+
+ + + per page +
+
+ +
+ + {% if pagination.has_prev %} + + + + + Previous + + {% else %} + + + + + Previous + + {% endif %} + + + {% set start_page = max(1, pagination.current_page - 2) %} + {% set end_page = min(pagination.total_pages, pagination.current_page + 2) %} + + {% if start_page > 1 %} + 1 + {% if start_page > 2 %} + ... + {% endif %} + {% endif %} + + {% for page_num in start_page..end_page %} + {% if page_num == pagination.current_page %} + {{ page_num }} + {% else %} + {{ page_num }} + {% endif %} + {% endfor %} + + {% if end_page < pagination.total_pages %} + {% if end_page < pagination.total_pages - 1 %} + ... + {% endif %} + {{ pagination.total_pages }} + {% endif %} + + + {% if pagination.has_next %} + + Next + + + + + {% else %} + + Next + + + + + {% endif %} +
+
+ {% endif %} + {% endif %} +
+
+ + + + + + +{% endblock %} diff --git a/resources/views/music/show.twig b/resources/views/music/show.twig index a152bda..762db5d 100644 --- a/resources/views/music/show.twig +++ b/resources/views/music/show.twig @@ -1,10 +1,10 @@ {% extends "layouts/app.twig" %} {% block content %} -
+
- -
- - - -

Music Details Coming Soon

-

{{ message }}

-
-
- - - - Music ID: {{ music.id }} + +
+
+ +
+ {% if album.cover_url %} + {{ album.title }} + {% else %} +
+ + + +
+ {% endif %} +
+ + +
+
+
+

{{ album.title }}

+ {% if artist %} +

+ {{ artist.name }} +

+ {% else %} +

{{ album.artist_name }}

+ {% endif %} +
+ {% if album.is_favorite %} +
+ + + + + Favorite + +
+ {% endif %} +
+ + +
+ {% if album.release_date %} +
+
Release Date
+
{{ album.release_date|date('M j, Y') }}
+
+ {% endif %} + {% if album.track_count %} +
+
Tracks
+
{{ album.track_count }}
+
+ {% endif %} + {% if album.total_duration_seconds %} +
+
Duration
+
{{ (album.total_duration_seconds / 60)|round(0) }} min
+
+ {% endif %} + {% if album.genre %} +
+
Genre
+
{{ album.genre }}
+
+ {% endif %} +
+ + +
+ Source: {{ album.source_name }} +
+ + +
+ + +
+ + + {% if tracks %} +
+
+

Tracks

+
+
+ {% for track in tracks %} +
+
+
+ +
+ {{ track.track_number }} +
+ + + + + +
+
{{ track.title }}
+ {% if track.artist_name and track.artist_name != album.artist_name %} +
{{ track.artist_name }}
+ {% endif %} +
+
+ + +
+ {% if track.duration_seconds %} + {{ '%02d'|format((track.duration_seconds / 60)|round(0)) }}:{{ '%02d'|format(track.duration_seconds % 60) }} + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if metadata %} +
+
+

Additional Information

+
+
+
+ {% if metadata.label %} +
+
Label
+
{{ metadata.label }}
+
+ {% endif %} + {% if metadata.popularity %} +
+
Popularity
+
{{ metadata.popularity }}/100
+
+ {% endif %} + {% if metadata.spotify_id %} +
+
Spotify ID
+
{{ metadata.spotify_id }}
+
+ {% endif %} + {% if metadata.musicbrainz_id %} +
+
MusicBrainz ID
+
{{ metadata.musicbrainz_id }}
+
+ {% endif %} +
+
+
+ {% endif %}
{% endblock %} diff --git a/sync-runner.php b/sync-runner.php index 624d3c4..67349fb 100644 --- a/sync-runner.php +++ b/sync-runner.php @@ -77,7 +77,7 @@ $sourceData = [ // Validate sync type if ($source['name'] === 'jellyfin') { - $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'cleanup']; + $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'music', 'cleanup']; if (!in_array($syncType, $validSyncTypes)) { echo "Invalid sync type for Jellyfin source. Valid types: " . implode(', ', $validSyncTypes) . "\n"; exit(1);