diff --git a/Makefile.am b/Makefile.am index 99025d4e..28cd688e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -13,7 +13,8 @@ lms_css_DATA = \ docroot/css/lms.css lms_images_DATA = \ - docroot/images/unknown-cover.jpg + docroot/images/unknown-cover.jpg \ + docroot/images/unknown-artist.jpg lms_approot_DATA = \ approot/templates.xml diff --git a/README.md b/README.md index ff89fca0..82b2b105 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,19 @@ LMS is a self-hosted media streaming software, released under the GPLv3 license. It allows you to access your music and your videos using an https web interface. ## Features - - Winamp-like interface, suited for large databases + - Winamp-like interface, suited for large media collections - Audio/Video transcode for maximum interoperability and low bandwith requirements - User management + - Multi genre + - MusicBrainzID support -Please note LMS is still under development and will gain more features in the future. +Please note LMS is still under development and will gain more features in the future: + - Radio function (using AcousticBrainz) + - Interface suited for mobile devices + - Video support + - Subsonic and/or Ampache API -LMS is written entirely in C++. Therefore, it is suitable to run on embedded devices, where space and/or memory are limited. +LMS is written entirely in C++. Therefore, it is suitable to run on embedded devices, where space and memory are limited. Please note some media files may require significant CPU usage to be transcoded. ## Dependencies @@ -42,13 +48,15 @@ $ make install This command requires root privileges ## Running - +Here is a command line example to run on port 5081: ```sh -$ /usr/bin/lms --docroot='/usr/share/lms/docroot/;/resources,/css,/images' --approot=/usr/share/lms/approot --http-address 0.0.0.0 --http-port 5081 +$ /usr/bin/lms --docroot='/usr/share/lms/docroot/;/resources,/css,/images,/favicon.ico' --approot=/usr/share/lms/approot --http-address 0.0.0.0 --http-port 5081 ``` It is highly recommended to run LMS as a non root user. The exectuable needs write accesses to the /var/lms/ directory. +To connect to LMS, just open your favorite browser and go to http://localhost:5081 + ## Mobile Add the following code in your wt_config.xml file: @@ -69,12 +77,14 @@ Generate a self signed certificate: ```sh $ openssl req -x509 -out cert.pem -keyout privkey.pem -newkey rsa:4096 ``` -Run: +Run (on port 5081): ```sh -$ /usr/bin/lms --docroot='/usr/share/lms/docroot/;/resources,/css,/images' --approot=/usr/share/lms/approot --https-address 0.0.0.0 --https-port 5081 --ssl-certificate cert.pem --ssl-private-key privkey.pem --ssl-tmp-dh dh2048.pem +$ /usr/bin/lms --docroot='/usr/share/lms/docroot/;/resources,/css,/images,/favicon.ico' --approot=/usr/share/lms/approot --https-address 0.0.0.0 --https-port 5081 --ssl-certificate cert.pem --ssl-private-key privkey.pem --ssl-tmp-dh dh2048.pem ``` Depending on your SSL parameters, you may be asked for the PEM passphrase to unlock the private key. +To connect to LMS, just open your favorite browser and go to https://localhost:5081 + ## Credits - Wt (http://www.webtoolkit.eu/) diff --git a/TODO b/TODO index 513e9f20..55ea8b4e 100644 --- a/TODO +++ b/TODO @@ -53,9 +53,9 @@ - Perform a search for each key pressed (using a timer, in JS) - Save user prefs in local storage (volume, loop, shuffle, current playqueue, etc.) [desktop] - - Better style... - TrackView: Reselect the current selected item when displaying the updated search results - Add keyboard shortcuts + - playqueue: try to get a single div to display the cover / artist / track name in order to improve style [mobile] - Reorganize the views to ease internal paths - Covers not always the same height diff --git a/approot/templates.xml b/approot/templates.xml index 411523a4..5be05553 100644 --- a/approot/templates.xml +++ b/approot/templates.xml @@ -356,21 +356,6 @@ - -
- ${prev} - ${play} - ${pause} - ${next} -
-
- - -
-
${search}
-
-
-
@@ -386,108 +371,79 @@
- -
-
-
${name}
-
-
-
- - -
-
-
-
-
-
${cover}
-
${name}
-
-
-
-
-
-
- - -
-
-
-
-
-
${cover}
-
${name}
-
${btn}
-
-
-
-
-
-
- - -
-
-
- ${contents} -
-
-
+ + + + ${title} + ${contents} + ${show-more} - -
-
${cover} -

${name}

${artist}

-
-
- - -
-
-
- ${contents} -
-
-
-
- - +

${gif}

${name}

- + + + ${title} + ${contents} + ${show-more} + + + +
+
${cover} +

${name}

${artist}

+
+
+ + + ${title} ${show-more} - + - + + ${controls} + + + + + ${load}${save}${clear}${shuffle}${repeat} + + + +
+
+
${cover}
+
${artist-name}
${track-name}
+
${play-btn}${del-btn}
+
+
${audio} ${prev}${play-pause}${next} -
+
- ${shuffle}${repeat} + ${}${shuffle}${}${}${repeat}${}${}${playqueue}${}
diff --git a/docroot/css/lms.css b/docroot/css/lms.css index 6c0bf27d..814622af 100644 --- a/docroot/css/lms.css +++ b/docroot/css/lms.css @@ -129,6 +129,19 @@ writing-mode: bt-lr; } +.mobile-btn { + margin: 8px; +} + +.mobile-btn-active { + color: #428bca; +} + +.mobile-btn i { + cursor: pointer; + text-shadow: 0px 2px 3px rgba(0, 0, 0, 0.4); +} + .mobile-search-title { font-weight: bold; height: 32px; @@ -198,3 +211,13 @@ max-width: 100%; height: auto; } + +.release-search-cover { + width: 512px; + min-width: 512px; + height: 512px; + min-height: 512px; + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/docroot/images/unknown-artist.jpg b/docroot/images/unknown-artist.jpg new file mode 100755 index 00000000..8c3285ef Binary files /dev/null and b/docroot/images/unknown-artist.jpg differ diff --git a/docroot/images/unknown-cover.jpg b/docroot/images/unknown-cover.jpg old mode 100644 new mode 100755 index 19ffc706..3adc6dc1 Binary files a/docroot/images/unknown-cover.jpg and b/docroot/images/unknown-cover.jpg differ diff --git a/src/Makefile.am b/src/Makefile.am index e6b0f919..5b9e6480 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -32,15 +32,21 @@ lms_SOURCES = \ $(srcdir)/ui/audio/desktop/TableFilter.cpp \ $(srcdir)/ui/audio/desktop/TrackView.cpp \ $(srcdir)/ui/audio/mobile/ArtistSearch.cpp \ + $(srcdir)/ui/audio/mobile/ArtistSearchView.cpp \ + $(srcdir)/ui/audio/mobile/ArtistView.cpp \ $(srcdir)/ui/audio/mobile/MobileAudio.cpp \ + $(srcdir)/ui/audio/mobile/MobilePlayQueue.cpp \ + $(srcdir)/ui/audio/mobile/PreviewSearchView.cpp \ $(srcdir)/ui/audio/mobile/ReleaseSearch.cpp \ - $(srcdir)/ui/audio/mobile/TrackReleaseView.cpp \ + $(srcdir)/ui/audio/mobile/ReleaseSearchView.cpp \ + $(srcdir)/ui/audio/mobile/ReleaseView.cpp \ $(srcdir)/ui/audio/mobile/TrackSearch.cpp \ + $(srcdir)/ui/audio/mobile/TrackSearchView.cpp \ $(srcdir)/ui/common/DirectoryValidator.cpp \ $(srcdir)/ui/common/InputRange.cpp \ $(srcdir)/ui/common/LineEdit.cpp \ $(srcdir)/ui/resource/AvConvTranscodeStreamResource.cpp \ - $(srcdir)/ui/resource/CoverResource.cpp \ + $(srcdir)/ui/resource/ImageResource.cpp \ $(srcdir)/ui/resource/TranscodeResource.cpp \ $(srcdir)/ui/settings/Settings.cpp \ $(srcdir)/ui/settings/SettingsAccountFormView.cpp \ diff --git a/src/cover/CoverArtGrabber.cpp b/src/cover/CoverArtGrabber.cpp index 1cfe38ef..5eed27fb 100644 --- a/src/cover/CoverArtGrabber.cpp +++ b/src/cover/CoverArtGrabber.cpp @@ -96,15 +96,16 @@ std::vector Grabber::getCoverPaths(const boost::filesystem::path& directoryPath, std::size_t nbMaxCovers) const { std::vector res; + boost::system::error_code ec; // TODO handle preferred file names - boost::filesystem::directory_iterator itPath(directoryPath); + boost::filesystem::directory_iterator itPath(directoryPath, ec); boost::filesystem::directory_iterator itEnd; - while (itPath != itEnd) + while (!ec && itPath != itEnd) { boost::filesystem::path path = *itPath; - itPath++; + itPath.increment(ec); if (!boost::filesystem::is_regular(path)) continue; @@ -122,7 +123,6 @@ Grabber::getCoverPaths(const boost::filesystem::path& directoryPath, std::size_t if (res.size() >= nbMaxCovers) break; } - return res; } diff --git a/src/database-updater/DatabaseUpdater.cpp b/src/database-updater/DatabaseUpdater.cpp index 33afd506..872f8478 100644 --- a/src/database-updater/DatabaseUpdater.cpp +++ b/src/database-updater/DatabaseUpdater.cpp @@ -23,6 +23,7 @@ #include #include "logger/Logger.hpp" +#include "utils/Utils.hpp" #include "database/Types.hpp" @@ -244,7 +245,10 @@ Updater::process(boost::system::error_code err) LMS_LOG(DBUPDATER, INFO) << "Processing root directory '" << rootDirectory.path << "' DONE"; } - LMS_LOG(DBUPDATER, INFO) << "Scan complete. Changes = " << stats.nbChanges() << ", Errors = " << stats.nbScanErrors; + if (_running) + checkDuplicatedAudioFiles(stats); + + LMS_LOG(DBUPDATER, INFO) << "Scan complete. Scanned = " << stats.nbScanned << ", Skipped = " << stats.nbSkipped << ", Changes = " << stats.nbChanges() << " (added = " << stats.nbAdded << ", nbRemoved = " << stats.nbRemoved << ", nbModified = " << stats.nbModified << "), Scan errors = " << stats.nbScanErrors << ", Not imported = " << stats.nbNotImported; // Update database stats boost::posix_time::ptime now = boost::posix_time::second_clock::local_time(); @@ -387,12 +391,26 @@ Updater::processAudioFile( const boost::filesystem::path& file, Stats& stats) Wt::Dbo::ptr track = Track::getByPath(_db.getSession(), file); if (track && track->getLastWriteTime() == lastWriteTime) + { + stats.nbSkipped++; + transaction.rollback(); return; + } + + transaction.rollback(); } MetaData::Items items; if (!_metadataParser.parse(file, items)) + { + stats.nbScanErrors++; return; + } + + stats.nbScanned++; + + std::vector checksum ; + computeCrc(file, checksum); Wt::Dbo::Transaction transaction(_db.getSession()); @@ -404,7 +422,7 @@ Updater::processAudioFile( const boost::filesystem::path& file, Stats& stats) if (items.find(MetaData::Type::AudioStreams) == items.end() || boost::any_cast >(items[MetaData::Type::AudioStreams]).empty()) { - LMS_LOG(DBUPDATER, DEBUG) << "Skipped '" << file << "' (no audio stream found)"; + LMS_LOG(DBUPDATER, INFO) << "Skipped '" << file << "' (no audio stream found)"; // If Track exists here, delete it! if (track) @@ -412,12 +430,13 @@ Updater::processAudioFile( const boost::filesystem::path& file, Stats& stats) track.remove(); stats.nbRemoved++; } + stats.nbNotImported++; return; } if (items.find(MetaData::Type::Duration) == items.end() || boost::any_cast(items[MetaData::Type::Duration]).total_seconds() <= 0) { - LMS_LOG(DBUPDATER, DEBUG) << "Skipped '" << file << "' (no duration or duration <= 0)"; + LMS_LOG(DBUPDATER, INFO) << "Skipped '" << file << "' (no duration or duration <= 0)"; // If Track exists here, delete it! if (track) @@ -425,171 +444,178 @@ Updater::processAudioFile( const boost::filesystem::path& file, Stats& stats) track.remove(); stats.nbRemoved++; } + stats.nbNotImported++; return; } // ***** Title std::string title; - if (items.find(MetaData::Type::Title) != items.end()) { - title = boost::any_cast(items[MetaData::Type::Title]); - } - else + if (items.find(MetaData::Type::Title) != items.end()) + { + title = boost::any_cast(items[MetaData::Type::Title]); + } + else + { + // TODO parse file name guess track etc. + // For now juste use file name as title + title = file.filename().string(); + } + + // ***** Genres + std::vector< Genre::pointer > genres; + { + std::list genreList; + + if (items.find(MetaData::Type::Genres) != items.end()) + genreList = boost::any_cast< std::list > (items[MetaData::Type::Genres]); + + genres = getGenres( genreList ); + } + assert( !genres.empty() ); + + // ***** Artist + Artist::pointer artist; + { + std::string artistName; + std::string artistMusicBrainzID; + + if (items.find(MetaData::Type::MusicBrainzArtistID) != items.end()) + artistMusicBrainzID = boost::any_cast(items[MetaData::Type::MusicBrainzArtistID] ); + + if (items.find(MetaData::Type::Artist) != items.end()) + artistName = boost::any_cast(items[MetaData::Type::Artist]); + + artist = getArtist(file, artistName, artistMusicBrainzID); + } + assert(artist); + + // ***** Release + Release::pointer release; + { + std::string releaseName; + std::string releaseMusicBrainzID; + + if (items.find(MetaData::Type::MusicBrainzAlbumID) != items.end()) + releaseMusicBrainzID = boost::any_cast(items[MetaData::Type::MusicBrainzAlbumID] ); + + if (items.find(MetaData::Type::Album) != items.end()) + releaseName = boost::any_cast(items[MetaData::Type::Album]); + + release = getRelease(file, releaseName, releaseMusicBrainzID); + } + assert(release); + + // If file already exist, update data + // Otherwise, create it + if (!track) + { + // Create a new song + track = Track::create(_db.getSession(), file); + LMS_LOG(DBUPDATER, INFO) << "Adding '" << file << "'"; + stats.nbAdded++; + } + else + { + LMS_LOG(DBUPDATER, INFO) << "Updating '" << file << "'"; + stats.nbModified++; + } + + assert(track); + + track.modify()->setChecksum(checksum); + track.modify()->setArtist(artist); + track.modify()->setRelease(release); + track.modify()->setLastWriteTime(lastWriteTime); + track.modify()->setName(title); + track.modify()->setDuration( boost::any_cast(items[MetaData::Type::Duration]) ); + track.modify()->setAddedTime( boost::posix_time::second_clock::local_time() ); + + { + std::string trackGenreList; + // Product genre list + for (Genre::pointer genre : genres) { - // TODO parse file name guess track etc. - // For now juste use file name as title - title = file.filename().string(); + if (!trackGenreList.empty()) + trackGenreList += ", "; + trackGenreList += genre->getName(); } + track.modify()->setGenres( trackGenreList ); + } + track.modify()->setGenres( genres ); - // ***** Genres - std::vector< Genre::pointer > genres; - { - std::list genreList; + if (items.find(MetaData::Type::TrackNumber) != items.end()) + track.modify()->setTrackNumber( boost::any_cast(items[MetaData::Type::TrackNumber]) ); - if (items.find(MetaData::Type::Genres) != items.end()) - genreList = boost::any_cast< std::list > (items[MetaData::Type::Genres]); + if (items.find(MetaData::Type::TotalTrack) != items.end()) + track.modify()->setTotalTrackNumber( boost::any_cast(items[MetaData::Type::TotalTrack]) ); - genres = getGenres( genreList ); - } - assert( !genres.empty() ); + if (items.find(MetaData::Type::DiscNumber) != items.end()) + track.modify()->setDiscNumber( boost::any_cast(items[MetaData::Type::DiscNumber]) ); - // ***** Artist - Artist::pointer artist; - { - std::string artistName; - std::string artistMusicBrainzID; + if (items.find(MetaData::Type::TotalDisc) != items.end()) + track.modify()->setTotalDiscNumber( boost::any_cast(items[MetaData::Type::TotalDisc]) ); - if (items.find(MetaData::Type::MusicBrainzArtistID) != items.end()) - artistMusicBrainzID = boost::any_cast(items[MetaData::Type::MusicBrainzArtistID] ); + if (items.find(MetaData::Type::Date) != items.end()) + track.modify()->setDate( boost::any_cast(items[MetaData::Type::Date]) ); - if (items.find(MetaData::Type::Artist) != items.end()) - artistName = boost::any_cast(items[MetaData::Type::Artist]); + if (items.find(MetaData::Type::OriginalDate) != items.end()) + { + track.modify()->setOriginalDate( boost::any_cast(items[MetaData::Type::OriginalDate]) ); - artist = getArtist(file, artistName, artistMusicBrainzID); - } - assert(artist); + // If a file has an OriginalDate but no date, set the date to ease filtering + if (items.find(MetaData::Type::Date) == items.end()) + track.modify()->setDate( boost::any_cast(items[MetaData::Type::OriginalDate]) ); + } - // ***** Release - Release::pointer release; - { - std::string releaseName; - std::string releaseMusicBrainzID; + if (items.find(MetaData::Type::MusicBrainzTrackID) != items.end()) + { + track.modify()->setMBID( boost::any_cast(items[MetaData::Type::MusicBrainzTrackID]) ); + } - if (items.find(MetaData::Type::MusicBrainzAlbumID) != items.end()) - releaseMusicBrainzID = boost::any_cast(items[MetaData::Type::MusicBrainzAlbumID] ); + if (items.find(MetaData::Type::HasCover) != items.end()) + { + bool hasCover = boost::any_cast(items[MetaData::Type::HasCover]); - if (items.find(MetaData::Type::Album) != items.end()) - releaseName = boost::any_cast(items[MetaData::Type::Album]); + track.modify()->setCoverType( hasCover ? Track::CoverType::Embedded : Track::CoverType::None ); + } - release = getRelease(file, releaseName, releaseMusicBrainzID); - } - assert(release); - - // If file already exist, update data - // Otherwise, create it - if (!track) - { - // Create a new song - track = Track::create(_db.getSession(), file); - LMS_LOG(DBUPDATER, INFO) << "Adding '" << file << "'"; - stats.nbAdded++; - } - else - { - LMS_LOG(DBUPDATER, INFO) << "Updating '" << file << "'"; - stats.nbModified++; - } - - assert(track); - - track.modify()->setArtist(artist); - track.modify()->setRelease(release); - track.modify()->setLastWriteTime(lastWriteTime); - track.modify()->setName(title); - track.modify()->setDuration( boost::any_cast(items[MetaData::Type::Duration]) ); - track.modify()->setAddedTime( boost::posix_time::second_clock::local_time() ); - - { - std::string trackGenreList; - // Product genre list - for (Genre::pointer genre : genres) - { - if (!trackGenreList.empty()) - trackGenreList += ", "; - trackGenreList += genre->getName(); - } - - track.modify()->setGenres( trackGenreList ); - } - track.modify()->setGenres( genres ); - - if (items.find(MetaData::Type::TrackNumber) != items.end()) - track.modify()->setTrackNumber( boost::any_cast(items[MetaData::Type::TrackNumber]) ); - - if (items.find(MetaData::Type::TotalTrack) != items.end()) - track.modify()->setTotalTrackNumber( boost::any_cast(items[MetaData::Type::TotalTrack]) ); - - if (items.find(MetaData::Type::DiscNumber) != items.end()) - track.modify()->setDiscNumber( boost::any_cast(items[MetaData::Type::DiscNumber]) ); - - if (items.find(MetaData::Type::TotalDisc) != items.end()) - track.modify()->setTotalDiscNumber( boost::any_cast(items[MetaData::Type::TotalDisc]) ); - - if (items.find(MetaData::Type::Date) != items.end()) - track.modify()->setDate( boost::any_cast(items[MetaData::Type::Date]) ); - - if (items.find(MetaData::Type::OriginalDate) != items.end()) - { - track.modify()->setOriginalDate( boost::any_cast(items[MetaData::Type::OriginalDate]) ); - - // If a file has an OriginalDate but no date, set the date to ease filtering - if (items.find(MetaData::Type::Date) == items.end()) - track.modify()->setDate( boost::any_cast(items[MetaData::Type::OriginalDate]) ); - } - - if (items.find(MetaData::Type::HasCover) != items.end()) - { - bool hasCover = boost::any_cast(items[MetaData::Type::HasCover]); - - track.modify()->setCoverType( hasCover ? Track::CoverType::Embedded : Track::CoverType::None ); - } - - transaction.commit(); + transaction.commit(); } -void + void Updater::processRootDirectory(RootDirectory rootDirectory, Stats& stats) { + boost::system::error_code ec; - if (!boost::filesystem::exists(rootDirectory.path) || !boost::filesystem::is_directory(rootDirectory.path)) - return; + boost::filesystem::recursive_directory_iterator itPath(rootDirectory.path, ec); - boost::filesystem::recursive_directory_iterator itPath(rootDirectory.path); boost::filesystem::recursive_directory_iterator itEnd; - while (itPath != itEnd) + while (!ec && itPath != itEnd) { + boost::filesystem::path path = *itPath; + itPath.increment(ec); + if (!_running) return; - if (boost::filesystem::is_regular(*itPath)) + if (boost::filesystem::is_regular(path)) { switch( rootDirectory.type ) { case Database::MediaDirectory::Audio: - if (isFileSupported(*itPath, _audioFileExtensions)) - processAudioFile( *itPath, stats ); + if (isFileSupported(path, _audioFileExtensions)) + processAudioFile(path, stats ); break; case Database::MediaDirectory::Video: - if (isFileSupported(*itPath, _videoFileExtensions)) - processVideoFile( *itPath, stats); + if (isFileSupported(path, _videoFileExtensions)) + processVideoFile(path, stats); break; } } - - ++itPath; } } @@ -714,6 +740,29 @@ Updater::checkAudioFiles( Stats& stats ) LMS_LOG(DBUPDATER, INFO) << "Check audio files done!"; } +void +Updater::checkDuplicatedAudioFiles(Stats& stats) +{ + LMS_LOG(DBUPDATER, INFO) << "Checking duplicated audio files"; + + Wt::Dbo::Transaction transaction(_db.getSession()); + + std::vector tracks = Database::Track::getMBIDDuplicates(_db.getSession()); + for (Track::pointer track : tracks) + { + LMS_LOG(DBUPDATER, INFO) << "Found duplicated MBID [" << track->getMBID() << "], file: " << track->getPath() << " - " << track->getArtist()->getName() << " - " << track->getName(); + } + + tracks = Database::Track::getChecksumDuplicates(_db.getSession()); + for (Track::pointer track : tracks) + { + LMS_LOG(DBUPDATER, INFO) << "Found duplicated checksum [" << bufferToString(track->getChecksum()) << "], file: " << track->getPath() << " - " << track->getArtist()->getName() << " - " << track->getName(); + } + + + LMS_LOG(DBUPDATER, INFO) << "Checking duplicated audio files done!"; +} + void Updater::checkVideoFiles( Stats& stats ) { @@ -741,80 +790,74 @@ Updater::checkVideoFiles( Stats& stats ) LMS_LOG(DBUPDATER, DEBUG) << "Check video files done!"; } + void Updater::processVideoFile( const boost::filesystem::path& file, Stats& stats) { - try { + // Check last update time + boost::posix_time::ptime lastWriteTime (boost::posix_time::from_time_t( boost::filesystem::last_write_time( file ) ) ); - // Check last update time - boost::posix_time::ptime lastWriteTime (boost::posix_time::from_time_t( boost::filesystem::last_write_time( file ) ) ); + Wt::Dbo::Transaction transaction(_db.getSession()); - Wt::Dbo::Transaction transaction(_db.getSession()); + // Skip file if last write is the same + Wt::Dbo::ptr