diff --git a/app/Http/Controllers/API/SongController.php b/app/Http/Controllers/API/SongController.php index 5c0a06da..f7b437fe 100644 --- a/app/Http/Controllers/API/SongController.php +++ b/app/Http/Controllers/API/SongController.php @@ -46,7 +46,7 @@ class SongController extends Controller return response()->json([ 'lyrics' => $song->lyrics, 'album_info' => $song->album->getInfo(), - 'artist_info' => $song->album->artist->getInfo(), + 'artist_info' => $song->artist->getInfo(), ]); } diff --git a/app/Listeners/TidyLibrary.php b/app/Listeners/TidyLibrary.php index 0523ed37..b9ca0b1b 100644 --- a/app/Listeners/TidyLibrary.php +++ b/app/Listeners/TidyLibrary.php @@ -2,9 +2,7 @@ namespace App\Listeners; -use App\Models\Album; -use App\Models\Artist; -use App\Models\Song; +use Media; class TidyLibrary { @@ -17,16 +15,10 @@ class TidyLibrary /** * Fired every time a LibraryChanged event is triggered. - * Remove empty albums and artists from our system. + * Tidies up our lib. */ public function handle() { - $inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id'); - $inUseAlbums[] = Album::UNKNOWN_ID; - Album::whereNotIn('id', $inUseAlbums)->delete(); - - $inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id'); - $inUseArtists[] = Artist::UNKNOWN_ID; - Artist::whereNotIn('id', $inUseArtists)->delete(); + Media::tidy(); } } diff --git a/app/Listeners/UpdateLastfmNowPlaying.php b/app/Listeners/UpdateLastfmNowPlaying.php index 1059a219..bc2c0d2b 100644 --- a/app/Listeners/UpdateLastfmNowPlaying.php +++ b/app/Listeners/UpdateLastfmNowPlaying.php @@ -34,13 +34,13 @@ class UpdateLastfmNowPlaying { if (!$this->lastfm->enabled() || !($sessionKey = $event->user->getLastfmSessionKey()) || - $event->song->album->artist->isUnknown() + $event->song->artist->isUnknown() ) { return; } $this->lastfm->updateNowPlaying( - $event->song->album->artist->name, + $event->song->artist->name, $event->song->title, $event->song->album->name === Album::UNKNOWN_NAME ? null : $event->song->album->name, $event->song->length, diff --git a/app/Models/Album.php b/app/Models/Album.php index f0369f6f..f0ad7b86 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -6,11 +6,12 @@ use App\Facades\Lastfm; use Illuminate\Database\Eloquent\Model; /** - * @property string cover The path to the album's cover - * @property bool has_cover If the album has a cover image + * @property string cover The path to the album's cover + * @property bool has_cover If the album has a cover image * @property int id - * @property string name Name of the album - * @property Artist artist The album's artist + * @property string name Name of the album + * @property bool is_compilation If the album is a compilation from multiple artists + * @property Artist artist The album's artist */ class Album extends Model { @@ -20,6 +21,7 @@ class Album extends Model protected $guarded = ['id']; protected $hidden = ['created_at', 'updated_at']; + protected $casts = ['is_compilation' => 'bool']; public function artist() { @@ -40,21 +42,23 @@ class Album extends Model * Get an album using some provided information. * * @param Artist $artist - * @param $name + * @param string $name + * @param bool $isCompilation * * @return self */ - public static function get(Artist $artist, $name) + public static function get(Artist $artist, $name, $isCompilation = false) { - // If an empty name is provided, turn it into our "Unknown Album" - $name = $name ?: self::UNKNOWN_NAME; + // If this is a compilation album, its artist must be "Various Artists" + if ($isCompilation) { + $artist = Artist::getVarious(); + } - $album = self::firstOrCreate([ + return self::firstOrCreate([ 'artist_id' => $artist->id, - 'name' => $name, + 'name' => $name ?: self::UNKNOWN_NAME, + 'is_compilation' => $isCompilation, ]); - - return $album; } /** diff --git a/app/Models/Artist.php b/app/Models/Artist.php index 038b99aa..4bb1c015 100644 --- a/app/Models/Artist.php +++ b/app/Models/Artist.php @@ -16,6 +16,8 @@ class Artist extends Model { const UNKNOWN_ID = 1; const UNKNOWN_NAME = 'Unknown Artist'; + const VARIOUS_ID = 2; + const VARIOUS_NAME = 'Various Artists'; protected $guarded = ['id']; @@ -31,6 +33,21 @@ class Artist extends Model return $this->id === self::UNKNOWN_ID; } + public function isVarious() + { + return $this->id === self::VARIOUS_ID; + } + + /** + * Get the "Various Artists" object. + * + * @return Artist + */ + public static function getVarious() + { + return self::find(self::VARIOUS_ID); + } + /** * Sometimes the tags extracted from getID3 are HTML entity encoded. * This makes sure they are always sane. diff --git a/app/Models/ContributingArtist.php b/app/Models/ContributingArtist.php new file mode 100644 index 00000000..9f4c1847 --- /dev/null +++ b/app/Models/ContributingArtist.php @@ -0,0 +1,8 @@ +getID3->analyze($this->path); - if (isset($info['error'])) { + if (isset($info['error']) || !isset($info['playtime_seconds'])) { return; } @@ -89,10 +89,6 @@ class File // Read on. getid3_lib::CopyTagsToComments($info); - if (!isset($info['playtime_seconds'])) { - return; - } - $track = 0; // Apparently track number can be stored with different indices as the following. @@ -109,6 +105,7 @@ class File $props = [ 'artist' => '', 'album' => '', + 'part_of_a_compilation' => false, 'title' => '', 'length' => $info['playtime_seconds'], 'track' => (int) $track, @@ -122,6 +119,10 @@ class File return $props; } + // getID3's 'part_of_a_compilation' value can either be null, ['0'], or ['1'] + // We convert it into a boolean here. + $props['part_of_a_compilation'] = !!array_get($comments, 'part_of_a_compilation', [false])[0]; + // We prefer id3v2 tags over others. if (!$artist = array_get($info, 'tags.id3v2.artist', [null])[0]) { $artist = array_get($comments, 'artist', [''])[0]; @@ -175,9 +176,11 @@ class File $info = array_intersect_key($info, array_flip($tags)); $artist = isset($info['artist']) ? Artist::get($info['artist']) : $this->song->album->artist; - $album = isset($info['album']) ? Album::get($artist, $info['album']) : $this->song->album; + $album = isset($info['album']) + ? Album::get($artist, $info['album'], array_get($info, 'part_of_a_compilation')) + : $this->song->album; } else { - $album = Album::get(Artist::get($info['artist']), $info['album']); + $album = Album::get(Artist::get($info['artist']), $info['album'], array_get($info, 'part_of_a_compilation')); } if (!empty($info['cover']) && !$album->has_cover) { @@ -190,8 +193,13 @@ class File $info['album_id'] = $album->id; + // If the song is part of a compilation, we set its artist as the contributing artist. + if (array_get($info, 'part_of_a_compilation')) { + $info['contributing_artist_id'] = Artist::get($info['artist'])->id; + } + // Remove these values from the info array, so that we can just use the array as model's input data. - array_forget($info, ['artist', 'album', 'cover']); + array_forget($info, ['artist', 'album', 'cover', 'part_of_a_compilation']); $song = Song::updateOrCreate(['id' => $this->hash], $info); $song->save(); diff --git a/app/Models/Song.php b/app/Models/Song.php index dd3d460a..970b63b3 100644 --- a/app/Models/Song.php +++ b/app/Models/Song.php @@ -31,6 +31,7 @@ class Song extends Model 'length' => 'float', 'mtime' => 'int', 'track' => 'int', + 'contributing_artist_id' => 'int', ]; /** @@ -40,6 +41,11 @@ class Song extends Model */ public $incrementing = false; + public function contributingArtist() + { + return $this->belongsTo(ContributingArtist::class); + } + public function album() { return $this->belongsTo(Album::class); @@ -60,7 +66,7 @@ class Song extends Model public function scrobble($timestamp) { // Don't scrobble the unknown guys. No one knows them. - if ($this->album->artist->isUnknown()) { + if ($this->artist->isUnknown()) { return false; } @@ -70,7 +76,7 @@ class Song extends Model } return Lastfm::scrobble( - $this->album->artist->name, + $this->artist->name, $this->title, $timestamp, $this->album->name === Album::UNKNOWN_NAME ? '' : $this->album->name, @@ -242,4 +248,18 @@ class Song extends Model // implementation of br2nl to fail with duplicated linebreaks. return str_replace(["\r\n", "\r", "\n"], '
', $value); } + + /** + * Get the correct artist of the song. + * If it's part of a compilation, that would be the contributing artist. + * Otherwise, it's the album artist. + * + * @return Artist + */ + public function getArtistAttribute() + { + return $this->contributing_artist_id + ? $this->contributingArtist + : $this->album->artist; + } } diff --git a/app/Services/Media.php b/app/Services/Media.php index b16e565d..588f1448 100644 --- a/app/Services/Media.php +++ b/app/Services/Media.php @@ -5,6 +5,8 @@ namespace App\Services; use App\Console\Commands\SyncMedia; use App\Events\LibraryChanged; use App\Libraries\WatchRecord\WatchRecordInterface; +use App\Models\Album; +use App\Models\Artist; use App\Models\File; use App\Models\Setting; use App\Models\Song; @@ -20,7 +22,17 @@ class Media * * @var array */ - protected $allTags = ['artist', 'album', 'title', 'length', 'track', 'lyrics', 'cover', 'mtime']; + protected $allTags = [ + 'artist', + 'album', + 'title', + 'length', + 'track', + 'lyrics', + 'cover', + 'mtime', + 'part_of_a_compilation', + ]; /** * Tags to be synced. @@ -190,4 +202,29 @@ class Media { return File::getHash($path); } + + /** + * Tidy up the library by deleting empty albums and artists. + */ + public function tidy() + { + $inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id')->toArray(); + $inUseAlbums[] = Album::UNKNOWN_ID; + Album::whereNotIn('id', $inUseAlbums)->delete(); + + $inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id')->toArray(); + + $contributingArtists = Song::distinct() + ->select('contributing_artist_id') + ->groupBy('contributing_artist_id') + ->get() + ->lists('contributing_artist_id') + ->toArray(); + + $inUseArtists = array_merge($inUseArtists, $contributingArtists); + $inUseArtists[] = Artist::UNKNOWN_ID; + $inUseArtists[] = Artist::VARIOUS_ID; + + Artist::whereNotIn('id', $inUseArtists)->delete(); + } } diff --git a/database/migrations/2016_04_15_121215_add_is_complilation_into_albums.php b/database/migrations/2016_04_15_121215_add_is_complilation_into_albums.php new file mode 100644 index 00000000..b38a4330 --- /dev/null +++ b/database/migrations/2016_04_15_121215_add_is_complilation_into_albums.php @@ -0,0 +1,31 @@ +boolean('is_compilation')->nullable()->defaults(false)->after('cover'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('albums', function (Blueprint $table) { + $table->dropColumn('is_compilation'); + }); + } +} diff --git a/database/migrations/2016_04_15_125237_add_contributing_artist_id_into_songs.php b/database/migrations/2016_04_15_125237_add_contributing_artist_id_into_songs.php new file mode 100644 index 00000000..66007cb6 --- /dev/null +++ b/database/migrations/2016_04_15_125237_add_contributing_artist_id_into_songs.php @@ -0,0 +1,32 @@ +integer('contributing_artist_id')->unsigned()->nullable()->after('album_id'); + $table->foreign('contributing_artist_id')->references('id')->on('artists')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('songs', function (Blueprint $table) { + $table->dropColumn('contributing_artist_id'); + }); + } +} diff --git a/database/migrations/2016_04_16_082627_create_various_artists.php b/database/migrations/2016_04_16_082627_create_various_artists.php new file mode 100644 index 00000000..ebeee337 --- /dev/null +++ b/database/migrations/2016_04_16_082627_create_various_artists.php @@ -0,0 +1,49 @@ +name === Artist::VARIOUS_NAME) { + goto ret; + } + + // There's an existing artist with that special ID, but it's not our Various Artist + // We move it to the end of the table. + $latestArtist = Artist::orderBy('id', 'DESC')->first(); + $existingArtist->id = $latestArtist->id + 1; + $existingArtist->save(); + } + + Artist::create([ + 'id' => Artist::VARIOUS_ID, + 'name' => Artist::VARIOUS_NAME, + ]); + + ret: + Artist::reguard(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + } +} diff --git a/resources/assets/js/components/main-wrapper/extra/index.vue b/resources/assets/js/components/main-wrapper/extra/index.vue index 27ed16e2..a549e45d 100644 --- a/resources/assets/js/components/main-wrapper/extra/index.vue +++ b/resources/assets/js/components/main-wrapper/extra/index.vue @@ -12,7 +12,7 @@
- +
diff --git a/resources/assets/js/components/main-wrapper/main-content/album.vue b/resources/assets/js/components/main-wrapper/main-content/album.vue index 0e875cdf..3814b86a 100644 --- a/resources/assets/js/components/main-wrapper/main-content/album.vue +++ b/resources/assets/js/components/main-wrapper/main-content/album.vue @@ -12,7 +12,9 @@ @click.prevent="showingControls = false"> - by {{ album.artist.name }} + by + {{ album.artist.name }} + {{ album.artist.name }} • {{ meta.songCount }} {{ meta.songCount | pluralize 'song' }} • @@ -43,6 +45,7 @@ import isMobile from 'ismobilejs'; import albumStore from '../../../stores/album'; + import artistStore from '../../../stores/artist'; import playback from '../../../services/playback'; import hasSongList from '../../../mixins/has-song-list'; @@ -57,6 +60,13 @@ }; }, + computed: { + isNormalArtist() { + return !artistStore.isVariousArtists(this.album.artist) + && !artistStore.isUnknownArtist(this.album.artist); + }, + }, + watch: { /** * Watch the album's song count. diff --git a/resources/assets/js/components/main-wrapper/main-content/artist.vue b/resources/assets/js/components/main-wrapper/main-content/artist.vue index 929fb871..1ca8f3b6 100644 --- a/resources/assets/js/components/main-wrapper/main-content/artist.vue +++ b/resources/assets/js/components/main-wrapper/main-content/artist.vue @@ -81,7 +81,6 @@ */ 'main-content-view:load': function (view, artist) { if (view === 'artist') { - artistStore.getSongsByArtist(artist); this.artist = artist; } }, diff --git a/resources/assets/js/components/main-wrapper/main-content/home.vue b/resources/assets/js/components/main-wrapper/main-content/home.vue index 467c5f93..522b15e6 100644 --- a/resources/assets/js/components/main-wrapper/main-content/home.vue +++ b/resources/assets/js/components/main-wrapper/main-content/home.vue @@ -24,7 +24,7 @@ {{ song.title }} - {{ song.album.artist.name }} – + {{ song.artist.name }} – {{ song.playCount }} {{ song.playCount | pluralize 'play' }} @@ -47,7 +47,7 @@ {{ song.title }} - {{ song.album.artist.name }} + {{ song.artist.name }} diff --git a/resources/assets/js/components/modals/edit-songs-form.vue b/resources/assets/js/components/modals/edit-songs-form.vue index 0f087083..9a0d8dbc 100644 --- a/resources/assets/js/components/modals/edit-songs-form.vue +++ b/resources/assets/js/components/modals/edit-songs-form.vue @@ -131,7 +131,7 @@ * @return {boolean} */ bySameArtist() { - return every(this.songs, song => song.album.artist.id === this.songs[0].album.artist.id); + return every(this.songs, song => song.artist.id === this.songs[0].artist.id); }, /** @@ -188,7 +188,7 @@ if (this.editSingle) { this.formData.title = this.songs[0].title; this.formData.albumName = this.songs[0].album.name; - this.formData.artistName = this.songs[0].album.artist.name; + this.formData.artistName = this.songs[0].artist.name; // If we're editing only one song and the song's info (including lyrics) // hasn't been loaded, load it now. @@ -206,7 +206,7 @@ } } else { this.formData.albumName = this.inSameAlbum ? this.songs[0].album.name : ''; - this.formData.artistName = this.bySameArtist ? this.songs[0].album.artist.name : ''; + this.formData.artistName = this.bySameArtist ? this.songs[0].artist.name : ''; this.loading = false; } }, diff --git a/resources/assets/js/components/shared/album-item.vue b/resources/assets/js/components/shared/album-item.vue index 2e2d007e..1e2af279 100644 --- a/resources/assets/js/components/shared/album-item.vue +++ b/resources/assets/js/components/shared/album-item.vue @@ -8,7 +8,8 @@