Merge branch 'develop' for release v3.25.0
This commit is contained in:
+1
-1
@@ -44,7 +44,7 @@ __Notes__:
|
||||
* a C++17 compiler is needed
|
||||
* ffmpeg version 4 minimum is required
|
||||
```sh
|
||||
apt-get install g++ cmake libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev
|
||||
apt-get install g++ cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev
|
||||
```
|
||||
__Notes__:
|
||||
* libpam0g-dev is optional (only for using PAM authentication)
|
||||
|
||||
@@ -13,6 +13,7 @@ A [demo instance](http://lms.demo.poupon.io) is available. Note the administrati
|
||||
* Multi-value tags: artists, genres, composers, lyricists, moods, ...
|
||||
* Compilation support
|
||||
* [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names
|
||||
* Scrobbling to [ListenBrainz](https://listenbrainz.org)
|
||||
* Disc subtitles support
|
||||
* ReplayGain support
|
||||
* Persistent play queue across sessions
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="col-lg-9">
|
||||
${media-directory}
|
||||
${media-directory-info class="help-block"}
|
||||
<span class="help-block">${tr:Lms.Admin.Database.path-help}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<message id="Lms.Admin.Database.menu-database"><i class="fa fa-fw fa-database" aria-hidden="true"></i> Music collection</message>
|
||||
<message id="Lms.Admin.Database.never">Never</message>
|
||||
<message id="Lms.Admin.Database.path">Media root directory</message>
|
||||
<message id="Lms.Admin.Database.path-help">Directories containing a <code>.lmsignore</code> file are skipped</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type">Recommendation engine</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.clusters">Tags based</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.features">Audio analysis based</message>
|
||||
@@ -193,6 +194,11 @@
|
||||
<message id="Lms.Settings.replaygain-mode.release">Album</message>
|
||||
<message id="Lms.Settings.replaygain-preamp">ReplayGain preamp</message>
|
||||
<message id="Lms.Settings.replaygain-preamp-no-rg-info">ReplayGain preamp (if no info)</message>
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler">Scrobbler</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler.internal">Internal</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.scrobbling.listenbrainz-token">ListenBrainz token</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Artist list mode</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">All artists</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Album artists</message>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<message id="Lms.Admin.Database.menu-database"><i class="fa fa-fw fa-database" aria-hidden="true"></i> Collection de musiques</message>
|
||||
<message id="Lms.Admin.Database.never">Jamais</message>
|
||||
<message id="Lms.Admin.Database.path">Dossier racine des fichiers de musique</message>
|
||||
<message id="Lms.Admin.Database.path-help">Les dossiers contenant un fichier <code>.lmsignore</code> sont ignorés</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type">Moteur de recommandation</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.clusters">Basé sur les tags</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.features">Basé sur l'analyse audio</message>
|
||||
@@ -193,6 +194,11 @@
|
||||
<message id="Lms.Settings.replaygain-mode.release">Album</message>
|
||||
<message id="Lms.Settings.replaygain-preamp">Pre-amplification ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-preamp-no-rg-info">Pre-amplification ReplayGain (si pas d'info)</message>
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler">Scrobbler</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler.internal">Interne</message>
|
||||
<message id="Lms.Settings.scrobbling.scrobbler.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.scrobbling.listenbrainz-token">Jeton ListenBrainz</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Mode de listage des artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Tous les artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Tous les artistes d'album</message>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<messages xmlns:if="Wt.WTemplate.conditions"
|
||||
nplurals="2" plural="n > 1 ? 1 : 0">
|
||||
|
||||
<!--Common-->
|
||||
<message id="Lms.add">添加</message>
|
||||
<message id="Lms.administration">管理</message>
|
||||
<message id="Lms.apply">应用</message>
|
||||
<message id="Lms.discard">撤销</message>
|
||||
<message id="Lms.cancel">取消</message>
|
||||
<message id="Lms.create">新建</message>
|
||||
<message id="Lms.loading">加载中...</message>
|
||||
<message id="Lms.login">登陆</message>
|
||||
<message id="Lms.logout"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> 注销</message>
|
||||
<message id="Lms.not-a-directory">不是一个目录</message>
|
||||
<message id="Lms.password">密码</message>
|
||||
<message id="Lms.password-bad-login-combination">无效的登陆 / 密码组合</message>
|
||||
<message id="Lms.password-client-throttled">登录已被限制,请稍后再试</message>
|
||||
<message id="Lms.password-confirm">确认密码</message>
|
||||
<message id="Lms.password-new">新密码</message>
|
||||
<message id="Lms.password-old">旧密码</message>
|
||||
<message id="Lms.password-too-weak">密码太弱</message>
|
||||
<message id="Lms.passwords-dont-match">密码不匹配</message>
|
||||
<message id="Lms.quit-other-session">另一个会话已打开,重新打开这一个?</message>
|
||||
<message id="Lms.save">保存</message>
|
||||
<message id="Lms.user">用户</message>
|
||||
|
||||
<!--Errors-->
|
||||
<message id="Lms.Error.artist-not-found">歌手未找到</message>
|
||||
<message id="Lms.Error.error-occurred">发生错误!</message>
|
||||
<message id="Lms.Error.go-home">返回主页</message>
|
||||
<message id="Lms.Error.release-not-found">发布未找到</message>
|
||||
<message id="Lms.Error.user-not-allowed">不允许执行此操作</message>
|
||||
<message id="Lms.Error.user-not-found">未找到此用户</message>
|
||||
|
||||
<!--Administration-->
|
||||
<message id="Lms.Admin.Database.daily">每日的</message>
|
||||
<message id="Lms.Admin.Database.database">音乐收藏</message>
|
||||
<message id="Lms.Admin.Database.hourly">每小时的</message>
|
||||
<message id="Lms.Admin.Database.immediate-scan">立即扫描!</message>
|
||||
<message id="Lms.Admin.Database.monthly">每月的</message>
|
||||
<message id="Lms.Admin.Database.menu-database"><i class="fa fa-fw fa-database" aria-hidden="true"></i> 音乐收藏</message>
|
||||
<message id="Lms.Admin.Database.never">从未</message>
|
||||
<message id="Lms.Admin.Database.path">媒体根目录</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type">推荐引擎</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.clusters">基于标签</message>
|
||||
<message id="Lms.Admin.Database.recommendation-engine-type.features">基于音频分析</message>
|
||||
<message id="Lms.Admin.Database.scan-complete">扫描完成: {1} 总文件, {2} 附加文件, {3} 升级文件, {4} 已删除文件, {5} 副本文件, {6} 错误文件</message>
|
||||
<message id="Lms.Admin.Database.scan-launched">扫描已完成!</message>
|
||||
<message id="Lms.Admin.Database.scan-options">扫描选项</message>
|
||||
<message id="Lms.Admin.Database.settings-saved">新设置已保存!</message>
|
||||
<message id="Lms.Admin.Database.tags">标签</message>
|
||||
<message id="Lms.Admin.Database.update-period">更新周期</message>
|
||||
<message id="Lms.Admin.Database.update-start-time">更新开始时间</message>
|
||||
<message id="Lms.Admin.Database.weekly">每周的</message>
|
||||
|
||||
<message id="Lms.Admin.ScannerController.bad-duration">无法获得音轨时间</message>
|
||||
<message id="Lms.Admin.ScannerController.cannot-parse-file">无法解析文件</message>
|
||||
<message id="Lms.Admin.ScannerController.cannot-read-file">无法读取文件</message>
|
||||
<message id="Lms.Admin.ScannerController.duplicates-header">{1} 备份文件:</message>
|
||||
<message id="Lms.Admin.ScannerController.errors-header">{1} 错误:</message>
|
||||
<message id="Lms.Admin.ScannerController.force-scan-now">立即强制全盘扫描</message>
|
||||
<message id="Lms.Admin.ScannerController.get-report">生成报告</message>
|
||||
<message id="Lms.Admin.ScannerController.last-scan">最后扫描</message>
|
||||
<message id="Lms.Admin.ScannerController.last-scan-not-available">不可用</message>
|
||||
<message id="Lms.Admin.ScannerController.last-scan-status">已扫描 {1} 文件 在 {2} 和 {3} ({4} 错误, {5} 副本)</message>
|
||||
<message id="Lms.Admin.ScannerController.no-audio-track">没有音轨</message>
|
||||
<message id="Lms.Admin.ScannerController.same-hash">复制文件哈希值</message>
|
||||
<message id="Lms.Admin.ScannerController.same-mbid">复制 MBID</message>
|
||||
<message id="Lms.Admin.ScannerController.scan-now">立即扫描</message>
|
||||
<message id="Lms.Admin.ScannerController.scanner">扫描器</message>
|
||||
<message id="Lms.Admin.ScannerController.status">状态</message>
|
||||
<message id="Lms.Admin.ScannerController.status-not-scheduled">无计划</message>
|
||||
<message id="Lms.Admin.ScannerController.status-scheduled">计划在 {1}</message>
|
||||
<message id="Lms.Admin.ScannerController.status-in-progress">扫描中: 阶段 {1}/{2}</message>
|
||||
<message id="Lms.Admin.ScannerController.step-checking-for-missing-files">检查文件中... {1}%</message>
|
||||
<message id="Lms.Admin.ScannerController.step-discovering-files">检索文件中: {1} 文件</message>
|
||||
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)...</message>
|
||||
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">重载相似引擎中 {1}%...</message>
|
||||
<message id="Lms.Admin.ScannerController.step-scanning-files">扫描文件中: {1}/{2} 文件 ({3}%)...</message>
|
||||
|
||||
<!--Users-->
|
||||
<message id="Lms.Admin.Users.add">新建用户</message>
|
||||
<message id="Lms.Admin.Users.admin">管理员</message>
|
||||
<message id="Lms.Admin.Users.del">删除</message>
|
||||
<message id="Lms.Admin.Users.del-user">删除用户</message>
|
||||
<message id="Lms.Admin.Users.del-user-name">确认要删除此用户?</message>
|
||||
<message id="Lms.Admin.Users.demo">演示</message>
|
||||
<message id="Lms.Admin.Users.edit">编辑</message>
|
||||
<message id="Lms.Admin.Users.users">用户</message>
|
||||
<message id="Lms.Admin.Users.menu-users"><i class="fa fa-fw fa-users" aria-hidden="true"></i> 用户</message>
|
||||
|
||||
<!--User-->
|
||||
<message id="Lms.Admin.User.demo-account">演示账号</message>
|
||||
<message id="Lms.Admin.User.demo-account-already-exists">演示账号已存在!</message>
|
||||
<message id="Lms.Admin.User.demo-password-invalid">演示密码必须是登录名!</message>
|
||||
<message id="Lms.Admin.User.last-login">最后登录</message>
|
||||
<message id="Lms.Admin.User.user-already-exists">用户已存在!</message>
|
||||
<message id="Lms.Admin.User.user-create">新建用户</message>
|
||||
<message id="Lms.Admin.User.user-created">新用户已建立!</message>
|
||||
<message id="Lms.Admin.User.user-edit">编辑用户 {1}</message>
|
||||
<message id="Lms.Admin.User.user-updated">用户已更新!</message>
|
||||
|
||||
<!--Wizard-->
|
||||
<message id="Lms.Admin.InitWizard.done">管理员账号已建立。请刷新后继续!</message>
|
||||
<message id="Lms.Admin.InitWizard.header">新建管理员账号</message>
|
||||
|
||||
<!--Auth-->
|
||||
<message id="Lms.Auth.remember-me">记住我</message>
|
||||
<message id="Lms.Auth.welcome">欢迎!</message>
|
||||
|
||||
<!--Explore-->
|
||||
<message id="Lms.Explore.add-filter">添加筛选</message>
|
||||
<message id="Lms.Explore.all">全部</message>
|
||||
<message id="Lms.Explore.artists">歌手</message>
|
||||
<message id="Lms.Explore.download">下载</message>
|
||||
<message id="Lms.Explore.filter-added">筛选已添加</message>
|
||||
<message id="Lms.Explore.filters">筛选</message>
|
||||
<message id="Lms.Explore.links">链接</message>
|
||||
<message id="Lms.Explore.most-played">播放最多</message>
|
||||
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz 歌手</message>
|
||||
<message id="Lms.Explore.musicbrainz-release">MusicBrainz 发布</message>
|
||||
<message id="Lms.Explore.play-last">播放列表</message>
|
||||
<message id="Lms.Explore.play-shuffled">随机播放</message>
|
||||
<message id="Lms.Explore.random">随机</message>
|
||||
<message id="Lms.Explore.recently-added">最近添加</message>
|
||||
<message id="Lms.Explore.recently-played">最近播放</message>
|
||||
<message id="Lms.Explore.releases">专辑</message>
|
||||
<message id="Lms.Explore.star">收藏</message>
|
||||
<message id="Lms.Explore.starred">已收藏</message>
|
||||
<message id="Lms.Explore.tracks">音轨</message>
|
||||
<message id="Lms.Explore.type">类型</message>
|
||||
<message id="Lms.Explore.unstar">取消收藏</message>
|
||||
<message id="Lms.Explore.value">值</message>
|
||||
<message id="Lms.Explore.various-artists">群星</message>
|
||||
|
||||
<!--Explore:Artist-->
|
||||
<message id="Lms.Explore.Artist.similar-artists">相似歌手</message>
|
||||
|
||||
<!--Explore:Artists-->
|
||||
<message id="Lms.Explore.Artists.linktype-all">所有歌手</message>
|
||||
<message id="Lms.Explore.Artists.linktype-artist">追踪歌手</message>
|
||||
<message id="Lms.Explore.Artists.linktype-composer">作曲</message>
|
||||
<message id="Lms.Explore.Artists.linktype-lyricist">作词</message>
|
||||
<message id="Lms.Explore.Artists.linktype-mixer">混响</message>
|
||||
<message id="Lms.Explore.Artists.linktype-producer">制片人</message>
|
||||
<message id="Lms.Explore.Artists.linktype-releaseartist">专辑歌手</message>
|
||||
<message id="Lms.Explore.Artists.linktype-remixer">混音师</message>
|
||||
|
||||
<!--Explore:Release-->
|
||||
<message id="Lms.Explore.Release.similar-releases">相似专辑</message>
|
||||
<message id="Lms.Explore.Release.copyright">版权所有</message>
|
||||
<message id="Lms.Explore.Release.disc">唱片 {1}</message>
|
||||
|
||||
<!--Explore:Search-->
|
||||
<message id="Lms.Explore.Search.search-placeholder">搜索中...</message>
|
||||
<message id="Lms.Explore.Search.search">搜索结果</message>
|
||||
|
||||
<!--Player-->
|
||||
<message id="Lms.Player.transcoding-active">转码有效</message>
|
||||
|
||||
<!--Playqueue-->
|
||||
<message id="Lms.PlayQueue.clear">清除</message>
|
||||
<message id="Lms.PlayQueue.nb-tracks">{1} 追踪</message>
|
||||
<message id="Lms.PlayQueue.nb-tracks-added">
|
||||
<plural case="0">添加 {1} 追踪</plural>
|
||||
<plural case="1">已添加 {1} 追踪</plural>
|
||||
</message>
|
||||
<message id="Lms.PlayQueue.playqueue">播放队列</message>
|
||||
<message id="Lms.PlayQueue.playqueue-full">播放队列已满!</message>
|
||||
<message id="Lms.PlayQueue.radio-mode">广播模式</message>
|
||||
<message id="Lms.PlayQueue.repeat">循环播放</message>
|
||||
<message id="Lms.PlayQueue.shuffle">随机播放</message>
|
||||
|
||||
<!--PlayHistory-->
|
||||
<message id="Lms.PlayHistory.playhistory">播放历史</message>
|
||||
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.appearance">外观</message>
|
||||
<message id="Lms.Settings.audio">音频</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">这些音频设置取决于您的本地浏览器!</message>
|
||||
<message id="Lms.Settings.change-password">更改密码</message>
|
||||
<message id="Lms.Settings.dark-mode">夜间模式</message>
|
||||
<message id="Lms.Settings.demo-cannot-save">使用演示账号时不能保存!</message>
|
||||
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> 设置</message>
|
||||
<message id="Lms.Settings.password-bad">无效密码</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">旧密码必须填写</message>
|
||||
<message id="Lms.Settings.replaygain-mode">ReplayGain 模式</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">没有 ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">自动模式</message>
|
||||
<message id="Lms.Settings.replaygain-mode.track">音轨</message>
|
||||
<message id="Lms.Settings.replaygain-mode.release">专辑</message>
|
||||
<message id="Lms.Settings.replaygain-preamp">ReplayGain 放大器</message>
|
||||
<message id="Lms.Settings.replaygain-preamp-no-rg-info">ReplayGain 放大器 (如果没有信息)</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">歌手列表模式</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">所有歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">专辑歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">追踪歌手</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic 应用程序接口</message>
|
||||
<message id="Lms.Settings.transcode">转码</message>
|
||||
<message id="Lms.Settings.transcode-bitrate">视频比特率</message>
|
||||
<message id="Lms.Settings.transcode-enable">开启转码</message>
|
||||
<message id="Lms.Settings.transcode-format">转码格式</message>
|
||||
<message id="Lms.Settings.transcode-format.matroska_opus">Matroska/Opus</message>
|
||||
<message id="Lms.Settings.transcode-format.mp3">MP3</message>
|
||||
<message id="Lms.Settings.transcode-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcode-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcode-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcode-mode">启动转码</message>
|
||||
<message id="Lms.Settings.transcode-mode.always">总是</message>
|
||||
<message id="Lms.Settings.transcode-mode.if-format-not-supported">仅当格式不支持时使用</message>
|
||||
<message id="Lms.Settings.transcode-mode.never">从不</message>
|
||||
<message id="Lms.Settings.settings-saved">新设置已保存!</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WMessageBox.Yes">是</message>
|
||||
<message id="Wt.WMessageBox.No">否</message>
|
||||
<message id="Wt.WValidator.Invalid">此字段不能为空</message>
|
||||
<message id="Wt.WDoubleValidator.BadRange">这个值应该在 {1} 和 {2}</message>
|
||||
</messages>
|
||||
|
||||
</messages>
|
||||
@@ -131,6 +131,27 @@
|
||||
</div>
|
||||
</div>
|
||||
${</if-has-subsonic-api>}
|
||||
<legend>${tr:Lms.Settings.scrobbling}</legend>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-3 control-label" for="${id:scrobbler}">
|
||||
${tr:Lms.Settings.scrobbling.scrobbler}
|
||||
</label>
|
||||
<div class="col-lg-9">
|
||||
${scrobbler}
|
||||
${scrobbler-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-3 control-label" for="${id:listenbrainz-token}">
|
||||
${tr:Lms.Settings.scrobbling.listenbrainz-token}
|
||||
</label>
|
||||
<div class="col-lg-9">
|
||||
${listenbrainz-token}
|
||||
${listenbrainz-token-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${<if-has-change-password>}
|
||||
<legend>${tr:Lms.Settings.change-password}</legend>
|
||||
<div class="form-horizontal">
|
||||
|
||||
+4
-1
@@ -34,7 +34,10 @@ deploy-path = "/";
|
||||
# Number of threads to be used to dispatch http requests (0 means auto detect)
|
||||
http-server-thread-count = 0;
|
||||
|
||||
# Acoustic brainz's root API
|
||||
# ListenBrainz root API
|
||||
listenbrainz-api-url = "https://api.listenbrainz.org/1/";
|
||||
|
||||
# Acousticbrainz root API
|
||||
acousticbrainz-api-url = "https://acousticbrainz.org/api/v1/";
|
||||
|
||||
# Authentication
|
||||
|
||||
@@ -20,12 +20,15 @@ LMS.mediaplayer = function () {
|
||||
var _root = {};
|
||||
var _elems = {};
|
||||
var _offset = 0;
|
||||
var _trackId = null;
|
||||
var _duration = 0;
|
||||
var _audioNativeSrc;
|
||||
var _audioTranscodeSrc;
|
||||
var _settings = {};
|
||||
var _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
var _gainNode = _audioCtx.createGain();
|
||||
var _playedDuration = 0;
|
||||
var _lastStartPlaying = null;
|
||||
|
||||
var _updateControls = function() {
|
||||
if (_elems.audio.paused) {
|
||||
@@ -38,16 +41,35 @@ LMS.mediaplayer = function () {
|
||||
}
|
||||
}
|
||||
|
||||
var _startTimer = function() {
|
||||
if (_lastStartPlaying == null)
|
||||
Wt.emit(_root, "scrobbleListenNow", _trackId);
|
||||
_lastStartPlaying = Date.now();
|
||||
}
|
||||
|
||||
var _stopTimer = function() {
|
||||
if (_lastStartPlaying != null) {
|
||||
_playedDuration += Date.now() - _lastStartPlaying;
|
||||
}
|
||||
}
|
||||
|
||||
var _resetTimer = function() {
|
||||
if (_lastStartPlaying != null)
|
||||
Wt.emit(_root, "scrobbleListenFinished", _trackId, _playedDuration);
|
||||
_playedDuration = 0;
|
||||
_lastStartPlaying = null;
|
||||
}
|
||||
|
||||
var _durationToString = function (duration) {
|
||||
var minutes = parseInt(duration / 60, 10);
|
||||
var seconds = parseInt(duration, 10) % 60;
|
||||
var minutes = parseInt(duration / 60, 10);
|
||||
var seconds = parseInt(duration, 10) % 60;
|
||||
|
||||
var res = "";
|
||||
var res = "";
|
||||
|
||||
res += minutes + ":";
|
||||
res += (seconds < 10 ? "0" + seconds : seconds);
|
||||
res += minutes + ":";
|
||||
res += (seconds < 10 ? "0" + seconds : seconds);
|
||||
|
||||
return res;
|
||||
return res;
|
||||
}
|
||||
|
||||
var _playTrack = function() {
|
||||
@@ -201,6 +223,10 @@ LMS.mediaplayer = function () {
|
||||
_elems.audio.addEventListener("playing", _updateControls);
|
||||
_elems.audio.addEventListener("pause", _updateControls);
|
||||
|
||||
_elems.audio.addEventListener("pause", _stopTimer);
|
||||
_elems.audio.addEventListener("playing", _startTimer);
|
||||
_elems.audio.addEventListener("waiting", _stopTimer);
|
||||
|
||||
_elems.audio.addEventListener("timeupdate", function() {
|
||||
_elems.progress.style.width = "" + ((_offset + _elems.audio.currentTime) / _duration) * 100 + "%";
|
||||
_elems.curtime.innerHTML = _durationToString(_offset + _elems.audio.currentTime);
|
||||
@@ -293,6 +319,10 @@ LMS.mediaplayer = function () {
|
||||
}
|
||||
|
||||
var loadTrack = function(params, autoplay) {
|
||||
_stopTimer();
|
||||
_resetTimer();
|
||||
|
||||
_trackId = params.trackId;
|
||||
_offset = 0;
|
||||
_duration = params.duration;
|
||||
_audioNativeSrc = params.nativeResource;
|
||||
|
||||
@@ -6,6 +6,7 @@ add_subdirectory(database)
|
||||
add_subdirectory(metadata)
|
||||
add_subdirectory(recommendation)
|
||||
add_subdirectory(scanner)
|
||||
add_subdirectory(scrobbling)
|
||||
add_subdirectory(som)
|
||||
add_subdirectory(subsonic)
|
||||
add_subdirectory(utils)
|
||||
|
||||
@@ -39,10 +39,11 @@
|
||||
#include "database/TrackFeatures.hpp"
|
||||
#include "database/User.hpp"
|
||||
|
||||
namespace Database {
|
||||
namespace Database
|
||||
{
|
||||
|
||||
using Version = std::size_t;
|
||||
static constexpr Version LMS_DATABASE_VERSION {29};
|
||||
static constexpr Version LMS_DATABASE_VERSION {30};
|
||||
|
||||
class VersionInfo
|
||||
{
|
||||
@@ -78,7 +79,7 @@ namespace Database {
|
||||
|
||||
private:
|
||||
int _version {LMS_DATABASE_VERSION};
|
||||
};
|
||||
};
|
||||
|
||||
void
|
||||
Session::doDatabaseMigrationIfNeeded()
|
||||
@@ -315,6 +316,19 @@ CREATE TABLE "user_backup" (
|
||||
_session.execute("DROP TABLE user");
|
||||
_session.execute("ALTER TABLE user_backup RENAME TO user");
|
||||
}
|
||||
else if (version == 29)
|
||||
{
|
||||
_session.execute("ALTER TABLE tracklist_entry ADD date_time TEXT");
|
||||
_session.execute("ALTER TABLE user ADD listenbrainz_token TEXT");
|
||||
_session.execute("ALTER TABLE user ADD scrobbler INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(User::defaultScrobbler)) + ")");
|
||||
_session.execute("ALTER TABLE track ADD recording_mbid TEXT");
|
||||
|
||||
_session.execute("DELETE from tracklist WHERE name = ?").bind("__played_tracks__");
|
||||
|
||||
// MBID changes
|
||||
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
|
||||
ScanSettings::get(*this).modify()->incScanVersion();
|
||||
}
|
||||
else
|
||||
{
|
||||
LMS_LOG(DB, ERROR) << "Database version " << version << " cannot be handled using migration";
|
||||
@@ -426,6 +440,7 @@ Session::prepareTables()
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)");
|
||||
_session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)");
|
||||
|
||||
@@ -229,13 +229,13 @@ Track::getLastWritten(Session& session, std::optional<Wt::WDateTime> after, cons
|
||||
}
|
||||
|
||||
std::vector<Track::pointer>
|
||||
Track::getAllWithMBIDAndMissingFeatures(Session& session)
|
||||
Track::getAllWithRecordingMBIDAndMissingFeatures(Session& session)
|
||||
{
|
||||
session.checkSharedLocked();
|
||||
|
||||
Wt::Dbo::collection<pointer> res = session.getDboSession().query<pointer>
|
||||
("SELECT t FROM track t")
|
||||
.where("LENGTH(t.mbid) > 0")
|
||||
.where("LENGTH(t.recording_mbid) > 0")
|
||||
.where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)");
|
||||
return std::vector<pointer>(res.begin(), res.end());
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
#include "database/User.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "SqlQuery.hpp"
|
||||
#include "StringViewTraits.hpp"
|
||||
|
||||
namespace Database {
|
||||
|
||||
TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
|
||||
TrackList::TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
|
||||
: _name {name},
|
||||
_type {type},
|
||||
_isPublic {isPublic},
|
||||
@@ -42,7 +43,7 @@ TrackList::TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo:
|
||||
}
|
||||
|
||||
TrackList::pointer
|
||||
TrackList::create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
|
||||
TrackList::create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user)
|
||||
{
|
||||
session.checkUniqueLocked();
|
||||
assert(user);
|
||||
@@ -54,7 +55,7 @@ TrackList::create(Session& session, const std::string& name, Type type, bool isP
|
||||
}
|
||||
|
||||
TrackList::pointer
|
||||
TrackList::get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr<User> user)
|
||||
TrackList::get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr<User> user)
|
||||
{
|
||||
session.checkSharedLocked();
|
||||
assert(user);
|
||||
@@ -147,22 +148,6 @@ TrackList::getEntries(std::optional<std::size_t> offset, std::optional<std::size
|
||||
return std::vector<Wt::Dbo::ptr<TrackListEntry>>(entries.begin(), entries.end());
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<TrackListEntry>>
|
||||
TrackList::getEntriesReverse(std::optional<std::size_t> offset, std::optional<std::size_t> size) const
|
||||
{
|
||||
assert(session());
|
||||
assert(IdIsValid(self()->id()));
|
||||
|
||||
Wt::Dbo::collection<Wt::Dbo::ptr<TrackListEntry>> entries =
|
||||
session()->find<TrackListEntry>()
|
||||
.where("tracklist_id = ?").bind(self().id())
|
||||
.orderBy("id DESC")
|
||||
.limit(size ? static_cast<int>(*size) : -1)
|
||||
.offset(offset ? static_cast<int>(*offset) : -1);
|
||||
|
||||
return std::vector<Wt::Dbo::ptr<TrackListEntry>>(entries.begin(), entries.end());
|
||||
}
|
||||
|
||||
static
|
||||
Wt::Dbo::Query<Artist::pointer>
|
||||
createArtistsQuery(Wt::Dbo::Session& session, const std::string& queryStr, IdType tracklistId, const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType)
|
||||
@@ -275,8 +260,9 @@ TrackList::getArtistsReverse(const std::set<IdType>& clusterIds, std::optional<T
|
||||
assert(session());
|
||||
assert(IdIsValid(self()->id()));
|
||||
|
||||
Wt::Dbo::collection<Artist::pointer> collection = createArtistsQuery(*session(), "SELECT DISTINCT a from artist a", self()->id(), clusterIds, linkType)
|
||||
.orderBy("p_e.id DESC")
|
||||
Wt::Dbo::collection<Artist::pointer> collection = createArtistsQuery(*session(), "SELECT a from artist a", self()->id(), clusterIds, linkType)
|
||||
.groupBy("a.id").having("p_e.date_time = MAX(p_e.date_time)")
|
||||
.orderBy("p_e.date_time DESC")
|
||||
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
|
||||
.offset(range ? static_cast<int>(range->offset) : -1);
|
||||
|
||||
@@ -298,8 +284,9 @@ TrackList::getReleasesReverse(const std::set<IdType>& clusterIds, std::optional<
|
||||
assert(session());
|
||||
assert(IdIsValid(self()->id()));
|
||||
|
||||
Wt::Dbo::collection<Release::pointer> collection = createReleasesQuery(*session(), "SELECT DISTINCT r from release r", self()->id(), clusterIds)
|
||||
.orderBy("p_e.id DESC")
|
||||
Wt::Dbo::collection<Release::pointer> collection = createReleasesQuery(*session(), "SELECT r from release r", self()->id(), clusterIds)
|
||||
.groupBy("r.id").having("p_e.date_time = MAX(p_e.date_time)")
|
||||
.orderBy("p_e.date_time DESC")
|
||||
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
|
||||
.offset(range ? static_cast<int>(range->offset) : -1);
|
||||
|
||||
@@ -322,8 +309,8 @@ TrackList::getTracksReverse(const std::set<IdType>& clusterIds, std::optional<Ra
|
||||
assert(IdIsValid(self()->id()));
|
||||
|
||||
Wt::Dbo::collection<Track::pointer> collection = createTracksQuery(*session(), self()->id(), clusterIds)
|
||||
.orderBy("p_e.id DESC")
|
||||
.groupBy("t.id")
|
||||
.groupBy("t.id").having("p_e.date_time = MAX(p_e.date_time)")
|
||||
.orderBy("p_e.date_time DESC")
|
||||
.limit(range ? static_cast<int>(range->limit) + 1 : -1)
|
||||
.offset(range ? static_cast<int>(range->offset) : -1);
|
||||
|
||||
@@ -498,21 +485,22 @@ TrackList::getTopTracks(const std::set<IdType>& clusterIds, std::optional<Range>
|
||||
return res;
|
||||
}
|
||||
|
||||
TrackListEntry::TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist)
|
||||
: _track(track),
|
||||
_tracklist(tracklist)
|
||||
TrackListEntry::TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime)
|
||||
: _dateTime {Wt::WDateTime::fromTime_t(dateTime.toTime_t())} // force second resolution
|
||||
, _track {track}
|
||||
, _tracklist {tracklist}
|
||||
{
|
||||
|
||||
assert(_dateTime.isValid());
|
||||
}
|
||||
|
||||
TrackListEntry::pointer
|
||||
TrackListEntry::create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist)
|
||||
TrackListEntry::create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime)
|
||||
{
|
||||
session.checkUniqueLocked();
|
||||
assert(track);
|
||||
assert(tracklist);
|
||||
|
||||
auto res = session.getDboSession().add( std::make_unique<TrackListEntry>( track, tracklist) );
|
||||
auto res = session.getDboSession().add(std::make_unique<TrackListEntry>( track, tracklist, dateTime));
|
||||
session.getDboSession().flush();
|
||||
|
||||
return res;
|
||||
|
||||
@@ -68,7 +68,6 @@ AuthToken::getByValue(Session& session, const std::string& value)
|
||||
.where("value = ?").bind(value);
|
||||
}
|
||||
|
||||
static const std::string playedListName {"__played_tracks__"};
|
||||
static const std::string queuedListName {"__queued_tracks__"};
|
||||
|
||||
User::User(std::string_view loginName)
|
||||
@@ -109,7 +108,6 @@ User::create(Session& session, std::string_view loginName)
|
||||
|
||||
User::pointer user {session.getDboSession().add(std::make_unique<User>(loginName))};
|
||||
|
||||
TrackList::create(session, playedListName, TrackList::Type::Internal, false, user);
|
||||
TrackList::create(session, queuedListName, TrackList::Type::Internal, false, user);
|
||||
|
||||
session.getDboSession().flush();
|
||||
@@ -143,15 +141,6 @@ User::clearAuthTokens()
|
||||
_authTokens.clear();
|
||||
}
|
||||
|
||||
Wt::Dbo::ptr<TrackList>
|
||||
User::getPlayedTrackList(Session& session) const
|
||||
{
|
||||
assert(self());
|
||||
session.checkSharedLocked();
|
||||
|
||||
return TrackList::get(session, playedListName, TrackList::Type::Internal, self());
|
||||
}
|
||||
|
||||
Wt::Dbo::ptr<TrackList>
|
||||
User::getQueuedTrackList(Session& session) const
|
||||
{
|
||||
|
||||
@@ -81,7 +81,7 @@ class Track : public Wt::Dbo::Dbo<Track>
|
||||
static std::vector<std::pair<IdType, std::filesystem::path>> getAllPaths(Session& session, std::optional<std::size_t> offset = std::nullopt, std::optional<std::size_t> size = std::nullopt);
|
||||
static std::vector<pointer> getMBIDDuplicates(Session& session);
|
||||
static std::vector<pointer> getLastWritten(Session& session, std::optional<Wt::WDateTime> after, const std::set<IdType>& clusters, std::optional<Range> range, bool& moreResults);
|
||||
static std::vector<pointer> getAllWithMBIDAndMissingFeatures(Session& session);
|
||||
static std::vector<pointer> getAllWithRecordingMBIDAndMissingFeatures(Session& session);
|
||||
static std::vector<IdType> getAllIdsWithFeatures(Session& session, std::optional<std::size_t> limit = {});
|
||||
static std::vector<IdType> getAllIdsWithClusters(Session& session, std::optional<std::size_t> limit = {});
|
||||
static std::vector<pointer> getStarred(Session& session,
|
||||
@@ -106,7 +106,8 @@ class Track : public Wt::Dbo::Dbo<Track>
|
||||
void setYear(int year) { _year = year; }
|
||||
void setOriginalYear(int year) { _originalYear = year; }
|
||||
void setHasCover(bool hasCover) { _hasCover = hasCover; }
|
||||
void setMBID(const std::optional<UUID>& MBID) { _MBID = MBID ? MBID->getAsString() : ""; }
|
||||
void setTrackMBID(const std::optional<UUID>& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; }
|
||||
void setRecordingMBID(const std::optional<UUID>& MBID) { _recordingMBID = MBID ? MBID->getAsString() : ""; }
|
||||
void setCopyright(const std::string& copyright) { _copyright = std::string(copyright, 0, _maxCopyrightLength); }
|
||||
void setCopyrightURL(const std::string& copyrightURL) { _copyrightURL = std::string(copyrightURL, 0, _maxCopyrightURLLength); }
|
||||
void setTrackReplayGain(float replayGain) { _trackReplayGain = replayGain; }
|
||||
@@ -131,7 +132,8 @@ class Track : public Wt::Dbo::Dbo<Track>
|
||||
Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; }
|
||||
Wt::WDateTime getAddedTime() const { return _fileAdded; }
|
||||
bool hasCover() const { return _hasCover; }
|
||||
std::optional<UUID> getMBID() const { return UUID::fromString(_MBID); }
|
||||
std::optional<UUID> getTrackMBID() const { return UUID::fromString(_trackMBID); }
|
||||
std::optional<UUID> getRecordingMBID() const { return UUID::fromString(_recordingMBID); }
|
||||
std::optional<std::string> getCopyright() const;
|
||||
std::optional<std::string> getCopyrightURL() const;
|
||||
std::optional<float> getTrackReplayGain() const { return _trackReplayGain; }
|
||||
@@ -166,7 +168,8 @@ class Track : public Wt::Dbo::Dbo<Track>
|
||||
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
|
||||
Wt::Dbo::field(a, _fileAdded, "file_added");
|
||||
Wt::Dbo::field(a, _hasCover, "has_cover");
|
||||
Wt::Dbo::field(a, _MBID, "mbid");
|
||||
Wt::Dbo::field(a, _trackMBID, "mbid");
|
||||
Wt::Dbo::field(a, _recordingMBID, "recording_mbid");
|
||||
Wt::Dbo::field(a, _copyright, "copyright");
|
||||
Wt::Dbo::field(a, _copyrightURL, "copyright_url");
|
||||
Wt::Dbo::field(a, _trackReplayGain, "track_replay_gain");
|
||||
@@ -201,7 +204,8 @@ class Track : public Wt::Dbo::Dbo<Track>
|
||||
Wt::WDateTime _fileLastWrite;
|
||||
Wt::WDateTime _fileAdded;
|
||||
bool _hasCover {};
|
||||
std::string _MBID; // Musicbrainz Identifier
|
||||
std::string _trackMBID;
|
||||
std::string _recordingMBID;
|
||||
std::string _copyright;
|
||||
std::string _copyrightURL;
|
||||
std::optional<float> _trackReplayGain;
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include <Wt/Dbo/Dbo.h>
|
||||
#include <Wt/WDateTime.h>
|
||||
|
||||
#include "Types.hpp"
|
||||
|
||||
@@ -45,11 +47,11 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
|
||||
enum class Type
|
||||
{
|
||||
Playlist, // user controlled playlists
|
||||
Internal, // current playqueue, history
|
||||
Internal, // internal usage (current playqueue, history, ...)
|
||||
};
|
||||
|
||||
TrackList() = default;
|
||||
TrackList(const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
|
||||
TrackList(std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
|
||||
|
||||
// Stats utility
|
||||
std::vector<Wt::Dbo::ptr<Artist>> getTopArtists(const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType, std::optional<Range> range, bool& moreResults) const;
|
||||
@@ -57,14 +59,14 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
|
||||
std::vector<Wt::Dbo::ptr<Track>> getTopTracks(const std::set<IdType>& clusterIds, std::optional<Range> range, bool& moreResults) const;
|
||||
|
||||
// Search utility
|
||||
static pointer get(Session& session, const std::string& name, Type type, Wt::Dbo::ptr<User> user);
|
||||
static pointer get(Session& session, std::string_view name, Type type, Wt::Dbo::ptr<User> user);
|
||||
static pointer getById(Session& session, IdType tracklistId);
|
||||
static std::vector<pointer> getAll(Session& session);
|
||||
static std::vector<pointer> getAll(Session& session, Wt::Dbo::ptr<User> user);
|
||||
static std::vector<pointer> getAll(Session& session, Wt::Dbo::ptr<User> user, Type type);
|
||||
|
||||
// Create utility
|
||||
static pointer create(Session& session, const std::string& name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
|
||||
static pointer create(Session& session, std::string_view name, Type type, bool isPublic, Wt::Dbo::ptr<User> user);
|
||||
|
||||
// Accessors
|
||||
std::string getName() const { return _name; }
|
||||
@@ -82,7 +84,6 @@ class TrackList : public Wt::Dbo::Dbo<TrackList>
|
||||
std::size_t getCount() const;
|
||||
Wt::Dbo::ptr<TrackListEntry> getEntry(std::size_t pos) const;
|
||||
std::vector<Wt::Dbo::ptr<TrackListEntry>> getEntries(std::optional<std::size_t> offset = {}, std::optional<std::size_t> size = {}) const;
|
||||
std::vector<Wt::Dbo::ptr<TrackListEntry>> getEntriesReverse(std::optional<std::size_t> offset = {}, std::optional<std::size_t> size = {}) const;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Artist>> getArtistsReverse(const std::set<IdType>& clusterIds, std::optional<TrackArtistLinkType> linkType, std::optional<Range> range, bool& moreResults) const;
|
||||
std::vector<Wt::Dbo::ptr<Release>> getReleasesReverse(const std::set<IdType>& clusterIds, std::optional<Range> range, bool& moreResults) const;
|
||||
@@ -129,26 +130,31 @@ class TrackListEntry : public Wt::Dbo::Dbo<TrackListEntry>
|
||||
using pointer = Wt::Dbo::ptr<TrackListEntry>;
|
||||
|
||||
TrackListEntry() = default;
|
||||
TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist);
|
||||
TrackListEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime);
|
||||
|
||||
// find utility
|
||||
static pointer getById(Session& session, IdType id);
|
||||
|
||||
// Create utility
|
||||
static pointer create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist);
|
||||
static pointer create(Session& session, Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<TrackList> tracklist, const Wt::WDateTime& dateTime = Wt::WDateTime::currentDateTime());
|
||||
|
||||
// Accessors
|
||||
Wt::Dbo::ptr<Track> getTrack() const { return _track; }
|
||||
const Wt::WDateTime& getDateTime() const { return _dateTime; }
|
||||
|
||||
template<class Action>
|
||||
void persist(Action& a)
|
||||
{
|
||||
Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade);
|
||||
Wt::Dbo::belongsTo(a, _tracklist, "tracklist", Wt::Dbo::OnDeleteCascade);
|
||||
Wt::Dbo::field(a, _dateTime, "date_time");
|
||||
|
||||
Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade);
|
||||
Wt::Dbo::belongsTo(a, _tracklist, "tracklist", Wt::Dbo::OnDeleteCascade);
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
Wt::Dbo::ptr<Track> _track;
|
||||
Wt::WDateTime _dateTime;
|
||||
Wt::Dbo::ptr<Track> _track;
|
||||
Wt::Dbo::ptr<TrackList> _tracklist;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <Wt/Dbo/ptr.h>
|
||||
|
||||
namespace Database
|
||||
@@ -51,5 +52,23 @@ namespace Database
|
||||
Writer,
|
||||
};
|
||||
|
||||
// User selectable audio file formats
|
||||
// Do not change values
|
||||
enum class AudioFormat
|
||||
{
|
||||
MP3 = 1,
|
||||
OGG_OPUS = 2,
|
||||
OGG_VORBIS = 3,
|
||||
WEBM_VORBIS = 4,
|
||||
MATROSKA_OPUS = 5,
|
||||
};
|
||||
using Bitrate = std::uint32_t;
|
||||
|
||||
// Do not change enum values!
|
||||
enum class Scrobbler
|
||||
{
|
||||
Internal = 0,
|
||||
ListenBrainz = 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <Wt/Dbo/Dbo.h>
|
||||
#include <Wt/WDateTime.h>
|
||||
|
||||
#include "utils/UUID.hpp"
|
||||
#include "Types.hpp"
|
||||
|
||||
namespace Database {
|
||||
@@ -37,19 +38,6 @@ class Session;
|
||||
class TrackList;
|
||||
class Track;
|
||||
|
||||
// User selectable audio file formats
|
||||
// Do not change values
|
||||
enum class AudioFormat
|
||||
{
|
||||
MP3 = 1,
|
||||
OGG_OPUS = 2,
|
||||
OGG_VORBIS = 3,
|
||||
WEBM_VORBIS = 4,
|
||||
MATROSKA_OPUS = 5,
|
||||
};
|
||||
|
||||
using Bitrate = std::size_t;
|
||||
|
||||
class User;
|
||||
class AuthToken
|
||||
{
|
||||
@@ -139,6 +127,7 @@ class User : public Wt::Dbo::Dbo<User>
|
||||
static inline const Bitrate defaultSubsonicTranscodeBitrate {128000};
|
||||
static inline const UITheme defaultUITheme {UITheme::Dark};
|
||||
static inline const SubsonicArtistListMode defaultSubsonicArtistListMode {SubsonicArtistListMode::AllArtists};
|
||||
static inline const Scrobbler defaultScrobbler {Scrobbler::Internal};
|
||||
|
||||
|
||||
User() = default;
|
||||
@@ -172,6 +161,8 @@ class User : public Wt::Dbo::Dbo<User>
|
||||
void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; }
|
||||
void clearAuthTokens();
|
||||
void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; }
|
||||
void setScrobbler(Scrobbler scrobbler) { _scrobbler = scrobbler; }
|
||||
void setListenBrainzToken(const std::optional<UUID>& MBID) { _listenbrainzToken = MBID ? MBID->getAsString() : ""; }
|
||||
|
||||
// read
|
||||
bool isAdmin() const { return _type == Type::ADMIN; }
|
||||
@@ -184,8 +175,9 @@ class User : public Wt::Dbo::Dbo<User>
|
||||
bool isRadioSet() const { return _radio; }
|
||||
UITheme getUITheme() const { return _uiTheme; }
|
||||
SubsonicArtistListMode getSubsonicArtistListMode() const { return _subsonicArtistListMode; }
|
||||
Scrobbler getScrobbler() const { return _scrobbler; }
|
||||
std::optional<UUID> getListenBrainzToken() const { return UUID::fromString(_listenbrainzToken); }
|
||||
|
||||
Wt::Dbo::ptr<TrackList> getPlayedTrackList(Session& session) const;
|
||||
Wt::Dbo::ptr<TrackList> getQueuedTrackList(Session& session) const;
|
||||
|
||||
void starArtist(Wt::Dbo::ptr<Artist> artist);
|
||||
@@ -214,10 +206,14 @@ class User : public Wt::Dbo::Dbo<User>
|
||||
Wt::Dbo::field(a, _subsonicTranscodeBitrate, "subsonic_transcode_bitrate");
|
||||
Wt::Dbo::field(a, _subsonicArtistListMode, "subsonic_artist_list_mode");
|
||||
Wt::Dbo::field(a, _uiTheme, "ui_theme");
|
||||
// User's dynamic data
|
||||
Wt::Dbo::field(a, _scrobbler, "scrobbler");
|
||||
Wt::Dbo::field(a, _listenbrainzToken, "listenbrainz_token");
|
||||
|
||||
// UI settings
|
||||
Wt::Dbo::field(a, _curPlayingTrackPos, "cur_playing_track_pos");
|
||||
Wt::Dbo::field(a, _repeatAll, "repeat_all");
|
||||
Wt::Dbo::field(a, _radio, "radio");
|
||||
|
||||
Wt::Dbo::hasMany(a, _tracklists, Wt::Dbo::ManyToOne, "user");
|
||||
Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_artist_starred", "", Wt::Dbo::OnDeleteCascade);
|
||||
Wt::Dbo::hasMany(a, _starredReleases, Wt::Dbo::ManyToMany, "user_release_starred", "", Wt::Dbo::OnDeleteCascade);
|
||||
@@ -232,6 +228,8 @@ class User : public Wt::Dbo::Dbo<User>
|
||||
std::string _passwordHash;
|
||||
Wt::WDateTime _lastLogin;
|
||||
UITheme _uiTheme {defaultUITheme};
|
||||
Scrobbler _scrobbler {defaultScrobbler};
|
||||
std::string _listenbrainzToken; // Musicbrainz Identifier
|
||||
|
||||
// Admin defined settings
|
||||
Type _type {Type::REGULAR};
|
||||
|
||||
@@ -200,11 +200,14 @@ AvFormatParser::parse(const std::filesystem::path& p, bool debug)
|
||||
track.acoustID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ RELEASE TRACK ID"
|
||||
|| tag == "MUSICBRAINZ_RELEASETRACKID"
|
||||
|| tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ_RELEASETRACKID")
|
||||
{
|
||||
track.trackMBID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ/TRACK ID")
|
||||
{
|
||||
track.musicBrainzTrackID = UUID::fromString(value);
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "TSST"
|
||||
|| tag == "DISCSUBTITLE"
|
||||
|
||||
@@ -149,7 +149,6 @@ void
|
||||
TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::StringList& values, bool debug)
|
||||
{
|
||||
|
||||
// TODO validate MBID format
|
||||
if (debug)
|
||||
{
|
||||
std::vector<std::string> strs;
|
||||
@@ -168,11 +167,11 @@ TagLibParser::processTag(Track& track, const std::string& tag, const TagLib::Str
|
||||
else if (tag == "MUSICBRAINZ_RELEASETRACKID"
|
||||
|| tag == "MUSICBRAINZ RELEASE TRACK ID")
|
||||
{
|
||||
track.musicBrainzTrackID = UUID::fromString(value);
|
||||
track.trackMBID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ TRACK ID")
|
||||
track.musicBrainzRecordID = UUID::fromString(value);
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
else if (tag == "ACOUSTID_ID")
|
||||
track.acoustID = UUID::fromString(value);
|
||||
else if (tag == "TRACKTOTAL")
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace MetaData
|
||||
std::vector<Artist> artists;
|
||||
std::vector<Artist> albumArtists;
|
||||
std::string title;
|
||||
std::optional<UUID> musicBrainzTrackID;
|
||||
std::optional<UUID> musicBrainzRecordID;
|
||||
std::optional<UUID> trackMBID;
|
||||
std::optional<UUID> recordingMBID;
|
||||
std::optional<Album> album;
|
||||
Clusters clusters;
|
||||
std::chrono::milliseconds duration;
|
||||
|
||||
@@ -79,9 +79,9 @@ getJsonData(const UUID& mbid)
|
||||
}
|
||||
|
||||
std::string
|
||||
extractLowLevelFeatures(const UUID& mbid)
|
||||
extractLowLevelFeatures(const UUID& recordingMBID)
|
||||
{
|
||||
return getJsonData(mbid);
|
||||
return getJsonData(recordingMBID);
|
||||
}
|
||||
|
||||
} // namespace Scanner::AcousticBrainz
|
||||
|
||||
@@ -25,6 +25,6 @@ class UUID;
|
||||
|
||||
namespace AcousticBrainz
|
||||
{
|
||||
std::string extractLowLevelFeatures(const UUID& MBID);
|
||||
std::string extractLowLevelFeatures(const UUID& recordingMBID);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ using namespace Database;
|
||||
|
||||
namespace {
|
||||
|
||||
const std::filesystem::path excludeDirFileName {".lmsignore"};
|
||||
|
||||
Wt::WDate
|
||||
getNextMonday(Wt::WDate current)
|
||||
{
|
||||
@@ -74,7 +76,7 @@ isFileSupported(const std::filesystem::path& file, const std::unordered_set<std:
|
||||
}
|
||||
|
||||
bool
|
||||
isPathInParentPath(const std::filesystem::path& path, const std::filesystem::path& parentPath)
|
||||
isPathInMediaDirectory(const std::filesystem::path& path, const std::filesystem::path& rootPath)
|
||||
{
|
||||
std::filesystem::path curPath = path;
|
||||
|
||||
@@ -82,7 +84,11 @@ isPathInParentPath(const std::filesystem::path& path, const std::filesystem::pat
|
||||
{
|
||||
curPath = curPath.parent_path();
|
||||
|
||||
if (curPath == parentPath)
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(curPath / excludeDirFileName, ec))
|
||||
return false;
|
||||
|
||||
if (curPath == rootPath)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -434,7 +440,7 @@ Scanner::countAllFiles(ScanStats& stats)
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, excludeDirFileName);
|
||||
notifyInProgress(stepStats);
|
||||
}
|
||||
|
||||
@@ -537,15 +543,15 @@ Scanner::scan(bool forceScan)
|
||||
}
|
||||
|
||||
bool
|
||||
Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& MBID)
|
||||
Scanner::fetchTrackFeatures(Database::IdType trackId, const UUID& recordingMBID)
|
||||
{
|
||||
std::map<std::string, double> features;
|
||||
|
||||
LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for track '" << MBID.getAsString() << "'";
|
||||
const std::string data {AcousticBrainz::extractLowLevelFeatures(MBID)};
|
||||
LMS_LOG(DBUPDATER, INFO) << "Fetching low level features for recording '" << recordingMBID.getAsString() << "'";
|
||||
const std::string data {AcousticBrainz::extractLowLevelFeatures(recordingMBID)};
|
||||
if (data.empty())
|
||||
{
|
||||
LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", MBID = '" << MBID.getAsString() << "': cannot extract features using AcousticBrainz";
|
||||
LMS_LOG(DBUPDATER, ERROR) << "Track " << trackId << ", recording MBID = '" << recordingMBID.getAsString() << "': cannot extract features using AcousticBrainz";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -575,7 +581,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
|
||||
struct TrackInfo
|
||||
{
|
||||
Database::IdType id;
|
||||
UUID mbid;
|
||||
UUID recordingMBID;
|
||||
};
|
||||
|
||||
const auto tracksToFetch {[&]()
|
||||
@@ -584,9 +590,9 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
|
||||
|
||||
auto transaction {_dbSession.createSharedTransaction()};
|
||||
|
||||
auto tracks {Database::Track::getAllWithMBIDAndMissingFeatures(_dbSession)};
|
||||
auto tracks {Database::Track::getAllWithRecordingMBIDAndMissingFeatures(_dbSession)};
|
||||
for (const auto& track : tracks)
|
||||
res.emplace_back(TrackInfo {track.id(), *track->getMBID()});
|
||||
res.emplace_back(TrackInfo {track.id(), *track->getRecordingMBID()});
|
||||
|
||||
return res;
|
||||
}()};
|
||||
@@ -601,7 +607,7 @@ Scanner::fetchTrackFeatures(ScanStats& stats)
|
||||
if (_abortScan)
|
||||
return;
|
||||
|
||||
if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid))
|
||||
if (fetchTrackFeatures(trackToFetch.id, trackToFetch.recordingMBID))
|
||||
stats.featuresFetched++;
|
||||
|
||||
stepStats.processedElems++;
|
||||
@@ -818,7 +824,8 @@ Scanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanSt
|
||||
if (!trackInfo->year && trackInfo->originalYear)
|
||||
track.modify()->setYear(*trackInfo->originalYear);
|
||||
|
||||
track.modify()->setMBID(trackInfo->musicBrainzRecordID);
|
||||
track.modify()->setRecordingMBID(trackInfo->recordingMBID);
|
||||
track.modify()->setTrackMBID(trackInfo->trackMBID);
|
||||
track.modify()->setFeatures({}); // TODO: only if MBID changed?
|
||||
track.modify()->setHasCover(trackInfo->hasCover);
|
||||
track.modify()->setCopyright(trackInfo->copyright);
|
||||
@@ -855,7 +862,7 @@ Scanner::scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool fo
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, excludeDirFileName);
|
||||
|
||||
notifyInProgress(stepStats);
|
||||
}
|
||||
@@ -875,7 +882,7 @@ checkFile(const std::filesystem::path& p, const std::filesystem::path& mediaDire
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isPathInParentPath(p, mediaDirectory))
|
||||
if (!isPathInMediaDirectory(p, mediaDirectory))
|
||||
{
|
||||
LMS_LOG(DBUPDATER, INFO) << "Removing '" << p.string() << "': out of media directory";
|
||||
return false;
|
||||
@@ -1016,9 +1023,9 @@ Scanner::checkDuplicatedAudioFiles(ScanStats& stats)
|
||||
const std::vector<Track::pointer> tracks = Database::Track::getMBIDDuplicates(_dbSession);
|
||||
for (const Track::pointer& track : tracks)
|
||||
{
|
||||
if (track->getMBID())
|
||||
if (auto trackMBID {track->getTrackMBID()})
|
||||
{
|
||||
LMS_LOG(DBUPDATER, INFO) << "Found duplicated MBID [" << track->getMBID()->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName();
|
||||
LMS_LOG(DBUPDATER, INFO) << "Found duplicated Track MBID [" << trackMBID->getAsString() << "], file: " << track->getPath().string() << " - " << track->getName();
|
||||
stats.duplicates.emplace_back(ScanDuplicate {track.id(), DuplicateReason::SameMBID});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
add_library(lmsscrobbling SHARED
|
||||
impl/internal/InternalScrobbler.cpp
|
||||
impl/listenbrainz/ListenBrainzScrobbler.cpp
|
||||
impl/Scrobbling.cpp
|
||||
)
|
||||
|
||||
target_include_directories(lmsscrobbling INTERFACE
|
||||
include
|
||||
)
|
||||
|
||||
target_include_directories(lmsscrobbling PRIVATE
|
||||
include
|
||||
impl
|
||||
)
|
||||
|
||||
target_link_libraries(lmsscrobbling PUBLIC
|
||||
lmsdatabase
|
||||
lmsutils
|
||||
)
|
||||
|
||||
install(TARGETS lmsscrobbling DESTINATION lib)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
|
||||
#include "scrobbling/Listen.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
class Session;
|
||||
class TrackList;
|
||||
class User;
|
||||
}
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
|
||||
class IScrobbler
|
||||
{
|
||||
public:
|
||||
virtual ~IScrobbler() = default;
|
||||
|
||||
virtual void listenStarted(const Listen& listen) = 0;
|
||||
virtual void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) = 0;
|
||||
|
||||
virtual void addListen(const Listen& listen, const Wt::WDateTime& timePoint) = 0;
|
||||
|
||||
virtual Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IScrobbler> createScrobbler(std::string_view backendName);
|
||||
|
||||
} // ns Scrobbling
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "Scrobbling.hpp"
|
||||
|
||||
#include "database/Db.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
|
||||
#include "internal/InternalScrobbler.hpp"
|
||||
#include "listenbrainz/ListenBrainzScrobbler.hpp"
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
std::unique_ptr<IScrobbling>
|
||||
createScrobbling(Database::Db& db)
|
||||
{
|
||||
return std::make_unique<Scrobbling>(db);
|
||||
}
|
||||
|
||||
Scrobbling::Scrobbling(Database::Db& db)
|
||||
: _db {db}
|
||||
{
|
||||
_scrobblers.emplace(Database::Scrobbler::Internal, std::make_unique<InternalScrobbler>(_db));
|
||||
_scrobblers.emplace(Database::Scrobbler::ListenBrainz, std::make_unique<ListenBrainzScrobbler>(_db));
|
||||
}
|
||||
|
||||
void
|
||||
Scrobbling::listenStarted(const Listen& listen)
|
||||
{
|
||||
if (auto scrobbler {getUserScrobbler(listen.userId)})
|
||||
_scrobblers[*scrobbler]->listenStarted(listen);
|
||||
}
|
||||
|
||||
void
|
||||
Scrobbling::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
|
||||
{
|
||||
if (auto scrobbler {getUserScrobbler(listen.userId)})
|
||||
_scrobblers[*scrobbler]->listenFinished(listen, duration);
|
||||
}
|
||||
|
||||
void
|
||||
Scrobbling::addListen(const Listen& listen, Wt::WDateTime timePoint)
|
||||
{
|
||||
if (auto scrobbler {getUserScrobbler(listen.userId)})
|
||||
_scrobblers[*scrobbler]->addListen(listen, timePoint);
|
||||
}
|
||||
|
||||
std::optional<Database::Scrobbler>
|
||||
Scrobbling::getUserScrobbler(Database::IdType userId)
|
||||
{
|
||||
std::optional<Database::Scrobbler> scrobbler;
|
||||
|
||||
Database::Session& session {_db.getTLSSession()};
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
if (const Database::User::pointer user {Database::User::getById(session, userId)})
|
||||
scrobbler = user->getScrobbler();
|
||||
|
||||
return scrobbler;
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>>
|
||||
Scrobbling::getRecentArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>> res;
|
||||
if (history)
|
||||
res = history->getArtistsReverse(clusterIds, linkType, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>>
|
||||
Scrobbling::getRecentReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>> res;
|
||||
if (history)
|
||||
res = history->getReleasesReverse(clusterIds, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>>
|
||||
Scrobbling::getRecentTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>> res;
|
||||
if (history)
|
||||
res = history->getTracksReverse(clusterIds, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
// Top
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>>
|
||||
Scrobbling::getTopArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>> res;
|
||||
if (history)
|
||||
res = history->getTopArtists(clusterIds, linkType, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>>
|
||||
Scrobbling::getTopReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>> res;
|
||||
if (history)
|
||||
res = history->getTopReleases(clusterIds, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>>
|
||||
Scrobbling::getTopTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults)
|
||||
{
|
||||
const Wt::Dbo::ptr<Database::TrackList> history {getListensTrackList(session, user)};
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>> res;
|
||||
if (history)
|
||||
res = history->getTopTracks(clusterIds, range, moreResults);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList>
|
||||
Scrobbling::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
|
||||
{
|
||||
return _scrobblers[user->getScrobbler()]->getListensTrackList(session, user);
|
||||
}
|
||||
|
||||
} // ns Scrobbling
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "IScrobbler.hpp"
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
class Scrobbling : public IScrobbling
|
||||
{
|
||||
public:
|
||||
Scrobbling(Database::Db& db);
|
||||
|
||||
private:
|
||||
void listenStarted(const Listen& listen) override;
|
||||
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
|
||||
void addListen(const Listen& listen, Wt::WDateTime timePoint) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>> getRecentArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>> getRecentReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>> getRecentTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Artist>> getTopArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Release>> getTopReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
std::vector<Wt::Dbo::ptr<Database::Track>> getTopTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) override;
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user);
|
||||
|
||||
std::optional<Database::Scrobbler> getUserScrobbler(Database::IdType userId);
|
||||
|
||||
Database::Db& _db;
|
||||
std::unordered_map<Database::Scrobbler, std::unique_ptr<IScrobbler>> _scrobblers;
|
||||
};
|
||||
|
||||
} // ns Scrobbling
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "InternalScrobbler.hpp"
|
||||
|
||||
#include "database/Db.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
static const std::string historyTracklistName {"__scrobbler_internal_history__"};
|
||||
|
||||
InternalScrobbler::InternalScrobbler(Database::Db& db)
|
||||
: _db {db}
|
||||
{}
|
||||
|
||||
void
|
||||
InternalScrobbler::listenStarted(const Listen& /*listen*/)
|
||||
{
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
void
|
||||
InternalScrobbler::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
|
||||
{
|
||||
// record tracks that have been played for at least of few seconds...
|
||||
if (duration && *duration < std::chrono::seconds {5})
|
||||
return;
|
||||
|
||||
addListen(listen, Wt::WDateTime::currentDateTime());
|
||||
}
|
||||
|
||||
void
|
||||
InternalScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
Database::Session& session {_db.getTLSSession()};
|
||||
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
const Database::User::pointer user {Database::User::getById(session, listen.userId)};
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList> tracklist {getListensTrackList(session, user)};
|
||||
if (!tracklist)
|
||||
tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user);
|
||||
|
||||
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
|
||||
if (!track)
|
||||
return;
|
||||
|
||||
Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint);
|
||||
}
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList>
|
||||
InternalScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
|
||||
{
|
||||
return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user);
|
||||
}
|
||||
|
||||
} // Scrobbling
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "IScrobbler.hpp"
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
class InternalScrobbler final : public IScrobbler
|
||||
{
|
||||
public:
|
||||
InternalScrobbler(Database::Db& db);
|
||||
|
||||
private:
|
||||
void listenStarted(const Listen& listen) override;
|
||||
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
|
||||
|
||||
void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override;
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) override;
|
||||
|
||||
Database::Db& _db;
|
||||
};
|
||||
} // Scrobbling
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "ListenBrainzScrobbler.hpp"
|
||||
|
||||
#include <Wt/Json/Array.h>
|
||||
#include <Wt/Json/Object.h>
|
||||
#include <Wt/Json/Value.h>
|
||||
#include <Wt/Json/Serializer.h>
|
||||
|
||||
#include "database/Artist.hpp"
|
||||
#include "database/Db.hpp"
|
||||
#include "database/Release.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "utils/IConfig.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
|
||||
#define LOG(sev) LMS_LOG(SCROBBLING, sev) << "[listenbrainz] - "
|
||||
|
||||
namespace StringUtils
|
||||
{
|
||||
template<>
|
||||
std::optional<std::chrono::seconds>
|
||||
readAs(const std::string& str)
|
||||
{
|
||||
std::optional<std::chrono::seconds> res;
|
||||
|
||||
if (const std::optional<std::size_t> value {StringUtils::readAs<std::size_t>(str)})
|
||||
res = std::chrono::seconds {*value};
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
std::optional<UUID>
|
||||
getListenBrainzToken(Database::Session& session, Database::IdType userId)
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
const Database::User::pointer user {Database::User::getById(session, userId)};
|
||||
if (!user)
|
||||
return std::nullopt;
|
||||
|
||||
if (user->getScrobbler() != Database::Scrobbler::ListenBrainz)
|
||||
return std::nullopt;
|
||||
|
||||
return user->getListenBrainzToken();
|
||||
}
|
||||
|
||||
bool
|
||||
canBeScrobbled(Database::Session& session, Database::IdType trackId, std::chrono::seconds duration)
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
const Database::Track::pointer track {Database::Track::getById(session, trackId)};
|
||||
if (!track)
|
||||
return false;
|
||||
|
||||
const bool res {duration >= std::chrono::minutes(4) || (duration >= track->getDuration() / 2)};
|
||||
if (!res)
|
||||
LOG(DEBUG) << "Track cannot be scrobbled since played duration is too short: " << duration.count() << "s, total duration = " << std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count() << "s";
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::optional<Wt::Json::Object>
|
||||
listenToJsonPayload(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
|
||||
if (!track)
|
||||
return std::nullopt;
|
||||
|
||||
auto artists {track->getArtists({Database::TrackArtistLinkType::Artist})};
|
||||
if (artists.empty())
|
||||
artists = track->getArtists({Database::TrackArtistLinkType::ReleaseArtist});
|
||||
|
||||
if (artists.empty())
|
||||
{
|
||||
LOG(DEBUG) << "Track cannot be scrobbled since it does not have any artist";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Wt::Json::Object additionalInfo;
|
||||
additionalInfo["listening_from"] = "LMS";
|
||||
if (track->getRelease())
|
||||
{
|
||||
if (auto MBID {track->getRelease()->getMBID()})
|
||||
additionalInfo["release_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
|
||||
}
|
||||
|
||||
{
|
||||
Wt::Json::Array artistMBIDs;
|
||||
for (const Database::Artist::pointer& artist : artists)
|
||||
{
|
||||
if (auto MBID {artist->getMBID()})
|
||||
artistMBIDs.push_back(Wt::Json::Value {std::string {MBID->getAsString()}});
|
||||
}
|
||||
|
||||
if (!artistMBIDs.empty())
|
||||
additionalInfo["artist_mbids"] = std::move(artistMBIDs);
|
||||
}
|
||||
|
||||
if (auto MBID {track->getTrackMBID()})
|
||||
additionalInfo["track_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
|
||||
|
||||
if (auto MBID {track->getRecordingMBID()})
|
||||
additionalInfo["recording_mbid"] = Wt::Json::Value {std::string {MBID->getAsString()}};
|
||||
|
||||
if (const std::optional<std::size_t> trackNumber {track->getTrackNumber()})
|
||||
additionalInfo["tracknumber"] = Wt::Json::Value {static_cast<long long int>(*trackNumber)};
|
||||
|
||||
Wt::Json::Object trackMetadata;
|
||||
trackMetadata["additional_info"] = std::move(additionalInfo);
|
||||
trackMetadata["artist_name"] = Wt::Json::Value {artists.front()->getName()};
|
||||
trackMetadata["track_name"] = Wt::Json::Value {track->getName()};
|
||||
if (track->getRelease())
|
||||
trackMetadata["release_name"] = Wt::Json::Value {track->getRelease()->getName()};
|
||||
|
||||
Wt::Json::Object payload;
|
||||
payload["track_metadata"] = std::move(trackMetadata);
|
||||
if (timePoint.isValid())
|
||||
payload["listened_at"] = Wt::Json::Value {static_cast<long long int>(timePoint.toTime_t())};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
std::string
|
||||
listenToJsonString(Database::Session& session, const Scrobbling::Listen& listen, const Wt::WDateTime& timePoint, std::string_view listenType)
|
||||
{
|
||||
std::string res;
|
||||
|
||||
std::optional<Wt::Json::Object> payload {listenToJsonPayload(session, listen, timePoint)};
|
||||
if (!payload)
|
||||
return res;
|
||||
|
||||
Wt::Json::Object root;
|
||||
root["listen_type"] = Wt::Json::Value {std::string {listenType}};
|
||||
root["payload"] = Wt::Json::Array {std::move(*payload)};
|
||||
|
||||
res = Wt::Json::serialize(root);
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T>
|
||||
headerReadAs(const Wt::Http::Message& msg, std::string_view headerName)
|
||||
{
|
||||
std::optional<T> res;
|
||||
|
||||
if (const std::string* headerValue {msg.getHeader(std::string {headerName})})
|
||||
res = StringUtils::readAs<T>(*headerValue);
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
static const std::string historyTracklistName {"__scrobbler_listenbrainz_history__"};
|
||||
|
||||
ListenBrainzScrobbler::ListenBrainzScrobbler(Database::Db& db)
|
||||
: _apiEndpoint {Service<IConfig>::get()->getString("listenbrainz-api-url", "https://api.listenbrainz.org/1/")}
|
||||
, _db {db}
|
||||
{
|
||||
LOG(INFO) << "Starting ListenBrainz scrobbler... API endpoint = '" << _apiEndpoint << "'";
|
||||
|
||||
_client.done().connect([this](Wt::AsioWrapper::error_code ec, Wt::Http::Message msg)
|
||||
{
|
||||
onClientDone(ec, msg);
|
||||
});
|
||||
|
||||
_ioService.setThreadCount(1);
|
||||
_ioService.start();
|
||||
}
|
||||
|
||||
ListenBrainzScrobbler::~ListenBrainzScrobbler()
|
||||
{
|
||||
_ioService.stop();
|
||||
|
||||
LOG(INFO) << "Stopped ListenBrainz scrobbler";
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::listenStarted(const Listen& listen)
|
||||
{
|
||||
_ioService.post([=]
|
||||
{
|
||||
enqueListen(listen, Wt::WDateTime {});
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration)
|
||||
{
|
||||
if (duration && !canBeScrobbled(_db.getTLSSession(), listen.trackId, *duration))
|
||||
return;
|
||||
|
||||
Listen timedListen {listen};
|
||||
const Wt::WDateTime now {Wt::WDateTime::currentDateTime()};
|
||||
|
||||
_ioService.post([=]
|
||||
{
|
||||
enqueListen(timedListen, now);
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::addListen(const Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
assert(timePoint.isValid());
|
||||
|
||||
_ioService.post([=]
|
||||
{
|
||||
enqueListen(listen, timePoint);
|
||||
});
|
||||
}
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList>
|
||||
ListenBrainzScrobbler::getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user)
|
||||
{
|
||||
return Database::TrackList::get(session, historyTracklistName, Database::TrackList::Type::Internal, user);
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::enqueListen(const Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
if (!timePoint.isValid())
|
||||
{
|
||||
// If we are currently throttled, just replace the entry if it has no timePoint
|
||||
// in order to only report the newest track listened to
|
||||
// If not throttled, just search past the next current first message as it is being sent
|
||||
|
||||
const std::size_t offset {_state == State::Throttled ? std::size_t {0} : std::size_t {1}};
|
||||
if (_sendQueue.size() > offset)
|
||||
{
|
||||
_sendQueue.erase(std::remove_if(std::next(std::begin(_sendQueue), offset), std::end(_sendQueue),
|
||||
[&](const QueuedListen& queuedListen) { return queuedListen.listen.userId == listen.userId && !queuedListen.timePoint.isValid(); }), std::end(_sendQueue));
|
||||
}
|
||||
}
|
||||
|
||||
_sendQueue.emplace_back(QueuedListen {listen, timePoint});
|
||||
|
||||
LOG(DEBUG) << "listen queue size = " << _sendQueue.size();
|
||||
|
||||
if (_state == State::Idle)
|
||||
sendNextQueuedListen();
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::sendNextQueuedListen()
|
||||
{
|
||||
assert(_state == State::Idle);
|
||||
|
||||
while (!_sendQueue.empty())
|
||||
{
|
||||
if (sendListen(_sendQueue.front().listen, _sendQueue.front().timePoint))
|
||||
{
|
||||
_state = State::Sending;
|
||||
break;
|
||||
}
|
||||
|
||||
_sendQueue.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
bool
|
||||
ListenBrainzScrobbler::sendListen(const Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
Database::Session& session {_db.getTLSSession()};
|
||||
|
||||
const std::optional<UUID> listenBrainzToken {getListenBrainzToken(session, listen.userId)};
|
||||
if (!listenBrainzToken)
|
||||
return false;
|
||||
|
||||
std::string payload {listenToJsonString(session, listen, timePoint, timePoint.isValid() ? "single" : "playing_now")};
|
||||
if (payload.empty())
|
||||
{
|
||||
LOG(DEBUG) << "Cannot convert listen to json: skipping";
|
||||
return false;
|
||||
}
|
||||
|
||||
// now send this
|
||||
Wt::Http::Message message;
|
||||
message.addHeader("Authorization", "Token " + std::string {listenBrainzToken->getAsString()});
|
||||
message.addHeader("Content-Type", "application/json");
|
||||
message.addBodyText(payload);
|
||||
|
||||
const std::string endPoint {_apiEndpoint + "submit-listens"};
|
||||
if (!_client.post(endPoint, message))
|
||||
{
|
||||
LOG(ERROR) << "Cannot post to '" << endPoint << "': invalid scheme or URL?";
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG(DEBUG) << "Listen POST done to '" << endPoint << "'";
|
||||
return true;
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg)
|
||||
{
|
||||
assert(!_sendQueue.empty());
|
||||
QueuedListen& queuedListen {_sendQueue.front()};
|
||||
|
||||
_state = State::Idle;
|
||||
|
||||
LOG(DEBUG) << "POST done. status = " << msg.status() << ", msg = '" << msg.body() << "'";
|
||||
if (ec)
|
||||
{
|
||||
LOG(ERROR) << "Retry " << queuedListen.retryCount << ", client error: '" << ec.message() << "'";
|
||||
// may be a network error, try again later
|
||||
if (++queuedListen.retryCount > _maxRetryCount)
|
||||
_sendQueue.pop_front();
|
||||
|
||||
throttle(_defaultRetryWaitDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
bool mustThrottle{};
|
||||
|
||||
switch (msg.status())
|
||||
{
|
||||
case 429:
|
||||
mustThrottle = true;
|
||||
break;
|
||||
|
||||
case 200:
|
||||
if (queuedListen.timePoint.isValid())
|
||||
cacheListen(queuedListen.listen, queuedListen.timePoint);
|
||||
_sendQueue.pop_front();
|
||||
break;
|
||||
|
||||
default:
|
||||
LOG(ERROR) << "Submit error: '" << msg.body() << "'";
|
||||
_sendQueue.pop_front();
|
||||
break;
|
||||
}
|
||||
|
||||
const auto remainingCount {headerReadAs<std::size_t>(msg, "X-RateLimit-Remaining")};
|
||||
LOG(DEBUG) << "Remaining messages = " << (remainingCount ? *remainingCount : 0);
|
||||
if (mustThrottle || (remainingCount && *remainingCount == 0))
|
||||
{
|
||||
const auto waitDuration {headerReadAs<std::chrono::seconds>(msg, "X-RateLimit-Reset-In")};
|
||||
throttle(waitDuration.value_or(_defaultRetryWaitDuration));
|
||||
}
|
||||
else
|
||||
{
|
||||
sendNextQueuedListen();
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::throttle(std::chrono::seconds requestedDuration)
|
||||
{
|
||||
assert(_state == State::Idle);
|
||||
|
||||
const std::chrono::seconds duration {clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration)};
|
||||
LOG(DEBUG) << "Throttling for " << duration.count() << " seconds";
|
||||
|
||||
_ioService.schedule(duration, [this]
|
||||
{
|
||||
_state = State::Idle;
|
||||
sendNextQueuedListen();
|
||||
});
|
||||
_state = State::Throttled;
|
||||
}
|
||||
|
||||
void
|
||||
ListenBrainzScrobbler::cacheListen(const Listen& listen, const Wt::WDateTime& timePoint)
|
||||
{
|
||||
Database::Session& session {_db.getTLSSession()};
|
||||
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
const Database::User::pointer user {Database::User::getById(session, listen.userId)};
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
const Database::Track::pointer track {Database::Track::getById(session, listen.trackId)};
|
||||
if (!track)
|
||||
return;
|
||||
|
||||
Database::TrackList::pointer tracklist {getListensTrackList(session, user)};
|
||||
if (!tracklist)
|
||||
tracklist = Database::TrackList::create(session, historyTracklistName, Database::TrackList::Type::Internal, false, user);
|
||||
|
||||
Database::TrackListEntry::create(session, track, getListensTrackList(session, user), timePoint);
|
||||
}
|
||||
|
||||
} // Scrobbling
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
|
||||
#include <Wt/Http/Client.h>
|
||||
#include <Wt/WIOService.h>
|
||||
|
||||
#include "IScrobbler.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Db;
|
||||
class Session;
|
||||
class TrackList;
|
||||
}
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
class ListenBrainzScrobbler final : public IScrobbler
|
||||
{
|
||||
public:
|
||||
ListenBrainzScrobbler(Database::Db& db);
|
||||
~ListenBrainzScrobbler();
|
||||
|
||||
ListenBrainzScrobbler(const ListenBrainzScrobbler&) = delete;
|
||||
ListenBrainzScrobbler(const ListenBrainzScrobbler&&) = delete;
|
||||
ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&) = delete;
|
||||
ListenBrainzScrobbler& operator=(const ListenBrainzScrobbler&&) = delete;
|
||||
|
||||
private:
|
||||
void listenStarted(const Listen& listen) override;
|
||||
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
|
||||
void addListen(const Listen& listen, const Wt::WDateTime& timePoint) override;
|
||||
|
||||
Wt::Dbo::ptr<Database::TrackList> getListensTrackList(Database::Session& session, Wt::Dbo::ptr<Database::User> user) override;
|
||||
|
||||
void enqueListen(const Listen& listen, const Wt::WDateTime& timePoint);
|
||||
void sendNextQueuedListen();
|
||||
bool sendListen(const Listen& listen, const Wt::WDateTime& timePoint);
|
||||
void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg);
|
||||
void throttle(std::chrono::seconds duration);
|
||||
|
||||
void cacheListen(const Listen& listen, const Wt::WDateTime& timePoint);
|
||||
|
||||
enum class State
|
||||
{
|
||||
Idle,
|
||||
Throttled,
|
||||
Sending,
|
||||
};
|
||||
State _state {State::Idle};
|
||||
|
||||
const std::string _apiEndpoint;
|
||||
const std::size_t _maxRetryCount {2};
|
||||
const std::chrono::seconds _defaultRetryWaitDuration {30};
|
||||
const std::chrono::seconds _minRetryWaitDuration {1};
|
||||
const std::chrono::seconds _maxRetryWaitDuration {300};
|
||||
|
||||
Database::Db& _db;
|
||||
Wt::WIOService _ioService;
|
||||
Wt::Http::Client _client {_ioService};
|
||||
|
||||
struct QueuedListen
|
||||
{
|
||||
Listen listen;
|
||||
Wt::WDateTime timePoint;
|
||||
std::size_t retryCount {};
|
||||
};
|
||||
std::deque<QueuedListen> _sendQueue;
|
||||
};
|
||||
} // Scrobbling
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <set>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
|
||||
#include "scrobbling/Listen.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Artist;
|
||||
class Db;
|
||||
class Release;
|
||||
class Session;
|
||||
class Track;
|
||||
class User;
|
||||
}
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
|
||||
class IScrobbling
|
||||
{
|
||||
public:
|
||||
virtual ~IScrobbling() = default;
|
||||
|
||||
// Scrobbling
|
||||
virtual void listenStarted(const Listen& listen) = 0;
|
||||
virtual void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> playedDuration = std::nullopt) = 0;
|
||||
|
||||
virtual void addListen(const Listen& listen, Wt::WDateTime timePoint) = 0;
|
||||
|
||||
// Stats
|
||||
// From most recent to oldest
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Artist>> getRecentArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Release>> getRecentReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Track>> getRecentTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
|
||||
// Top
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Artist>> getTopArtists(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::TrackArtistLinkType> linkType,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Release>> getTopReleases(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
|
||||
virtual std::vector<Wt::Dbo::ptr<Database::Track>> getTopTracks(Database::Session& session,
|
||||
Wt::Dbo::ptr<Database::User> user,
|
||||
const std::set<Database::IdType>& clusterIds,
|
||||
std::optional<Database::Range> range,
|
||||
bool& moreResults) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IScrobbling> createScrobbling(Database::Db& db);
|
||||
|
||||
} // ns Scrobbling
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "database/Types.hpp"
|
||||
|
||||
namespace Scrobbling
|
||||
{
|
||||
struct Listen
|
||||
{
|
||||
Database::IdType userId {};
|
||||
Database::IdType trackId {};
|
||||
};
|
||||
} // ns Scrobbling
|
||||
|
||||
@@ -23,6 +23,7 @@ target_link_libraries(lmssubsonic PRIVATE
|
||||
lmsdatabase
|
||||
lmsrecommendation
|
||||
lmsscanner
|
||||
lmsscrobbling
|
||||
lmsutils
|
||||
std::filesystem
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "recommendation/IEngine.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/Random.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
@@ -815,7 +816,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3)
|
||||
else if (type == "frequent")
|
||||
{
|
||||
bool moreResults {};
|
||||
releases = user->getPlayedTrackList(context.dbSession)->getTopReleases({}, range, moreResults);
|
||||
releases = Service<Scrobbling::IScrobbling>::get()->getTopReleases(context.dbSession, user, {}, range, moreResults);
|
||||
}
|
||||
else if (type == "newest")
|
||||
{
|
||||
@@ -830,7 +831,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3)
|
||||
else if (type == "recent")
|
||||
{
|
||||
bool moreResults {};
|
||||
releases = user->getPlayedTrackList(context.dbSession)->getReleasesReverse({}, range, moreResults);
|
||||
releases = Service<Scrobbling::IScrobbling>::get()->getRecentReleases(context.dbSession, user, {}, range, moreResults);
|
||||
}
|
||||
else if (type == "starred")
|
||||
{
|
||||
@@ -1643,24 +1644,44 @@ Response
|
||||
handleScrobble(RequestContext& context)
|
||||
{
|
||||
const std::vector<Id> ids {getMandatoryMultiParametersAs<Id>(context.parameters, "id")};
|
||||
// TODO handle time in some way (need underlying refacto)
|
||||
const std::vector<unsigned long> times {getMultiParametersAs<unsigned long>(context.parameters, "time")};
|
||||
const bool submission{getParameterAs<bool>(context.parameters, "submission").value_or(true)};
|
||||
|
||||
// only for tracks
|
||||
if (!std::all_of(std::cbegin(ids), std::cend(ids), [](const Id& id) { return id.type == Id::Type::Track; }))
|
||||
throw BadParameterGenericError {"id"};
|
||||
|
||||
auto transaction {context.dbSession.createUniqueTransaction()};
|
||||
// playing now => no time to be provided
|
||||
if (!submission && !times.empty())
|
||||
throw BadParameterGenericError {"time"};
|
||||
|
||||
User::pointer user {User::getById(context.dbSession, context.userId)};
|
||||
if (!user)
|
||||
throw RequestedDataNotFoundError {};
|
||||
// playing now => only one at a time
|
||||
if (!submission && ids.size() > 1)
|
||||
throw BadParameterGenericError {"id"};
|
||||
|
||||
for (Id id : ids)
|
||||
// if multiple submissions, must have times
|
||||
if (ids.size() > 1 && ids.size() != times.size())
|
||||
throw BadParameterGenericError {"time"};
|
||||
|
||||
if (!submission)
|
||||
{
|
||||
Track::pointer track {Track::getById(context.dbSession, id.value)};
|
||||
if (!track)
|
||||
continue;
|
||||
|
||||
TrackListEntry::create(context.dbSession, track, user->getPlayedTrackList(context.dbSession));
|
||||
Service<Scrobbling::IScrobbling>::get()->listenStarted({context.userId, ids.front().value});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (times.empty())
|
||||
{
|
||||
Service<Scrobbling::IScrobbling>::get()->listenFinished({context.userId, ids.front().value});
|
||||
}
|
||||
else
|
||||
{
|
||||
for (std::size_t i {}; i < ids.size(); ++i)
|
||||
{
|
||||
const Database::IdType trackId {ids[i].value};
|
||||
const unsigned long time {times[i]};
|
||||
Service<Scrobbling::IScrobbling>::get()->addListen({context.userId, trackId}, Wt::WDateTime::fromTime_t(static_cast<std::time_t>(time / 1000)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response::createOkResponse(context);
|
||||
|
||||
@@ -46,6 +46,10 @@ namespace
|
||||
SystemException(int err, const std::string& errMsg)
|
||||
: ChildProcessException {errMsg + ": " + strerror(err)}
|
||||
{}
|
||||
|
||||
SystemException(boost::system::error_code ec, const std::string& errMsg)
|
||||
: ChildProcessException {errMsg + ": " + ec.message()}
|
||||
{}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +107,7 @@ ChildProcess::ChildProcess(boost::asio::io_context& ioContext, const std::filesy
|
||||
boost::system::error_code assignError;
|
||||
_childStdout.assign(pipe[0], assignError);
|
||||
if (assignError)
|
||||
LMS_LOG(CHILDPROCESS, ERROR) << "Assign failed: " << assignError.message();
|
||||
throw SystemException {assignError, "fork failed!"};
|
||||
}
|
||||
_childPID = res;
|
||||
}
|
||||
@@ -113,6 +117,7 @@ ChildProcess::~ChildProcess()
|
||||
{
|
||||
if (!_waited)
|
||||
{
|
||||
LMS_LOG(CHILDPROCESS, DEBUG) << "Closing child process...";
|
||||
{
|
||||
boost::system::error_code closeError;
|
||||
_childStdout.close(closeError);
|
||||
@@ -136,6 +141,7 @@ ChildProcess::drain()
|
||||
void
|
||||
ChildProcess::kill()
|
||||
{
|
||||
LMS_LOG(CHILDPROCESS, DEBUG) << "Killing child process...";
|
||||
::kill(_childPID, SIGKILL);
|
||||
}
|
||||
|
||||
@@ -162,36 +168,25 @@ ChildProcess::wait(bool block)
|
||||
void
|
||||
ChildProcess::asyncRead(std::byte* data, std::size_t bufferSize, ReadCallback callback)
|
||||
{
|
||||
assert(!finished());
|
||||
|
||||
boost::asio::async_read(_childStdout, boost::asio::buffer(data, bufferSize),
|
||||
[this, callback {std::move(callback)}](const boost::system::error_code& error, std::size_t bytesTransferred)
|
||||
{
|
||||
LMS_LOG(CHILDPROCESS, DEBUG) << "Async read cb - ec = '" << error.message() << "', bytesTransferred = " << bytesTransferred;
|
||||
|
||||
ReadResult readResult {ReadResult::Success};
|
||||
if (error)
|
||||
{
|
||||
if (error == boost::asio::error::operation_aborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
boost::system::error_code closeError;
|
||||
_childStdout.close(closeError);
|
||||
}
|
||||
_finished = true;
|
||||
|
||||
if (error == boost::asio::error::eof)
|
||||
{
|
||||
callback(ReadResult::EndOfFile, bytesTransferred);
|
||||
return;
|
||||
}
|
||||
readResult = ReadResult::EndOfFile;
|
||||
else
|
||||
{
|
||||
callback(ReadResult::Error, bytesTransferred);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
callback(ReadResult::Success, bytesTransferred);
|
||||
callback(readResult, bytesTransferred);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,6 +194,7 @@ void
|
||||
ChildProcess::asyncWaitForData(WaitCallback cb)
|
||||
{
|
||||
LMS_LOG(CHILDPROCESS, DEBUG) << "Async wait requested";
|
||||
assert(!finished());
|
||||
|
||||
_childStdout.async_wait(boost::asio::posix::stream_descriptor::wait_read,
|
||||
[cb {std::move(cb)}](const boost::system::error_code& ec)
|
||||
@@ -224,6 +220,6 @@ ChildProcess::readSome(std::byte* data, std::size_t bufferSize)
|
||||
bool
|
||||
ChildProcess::finished()
|
||||
{
|
||||
return !_childStdout.is_open();
|
||||
return _finished;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#pragma once
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -56,5 +53,6 @@ class ChildProcess : public IChildProcess
|
||||
FileDescriptor _childStdout;
|
||||
::pid_t _childPID {};
|
||||
bool _waited {};
|
||||
bool _finished {};
|
||||
std::optional<int> _exitCode;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ const char* getModuleName(Module mod)
|
||||
case Module::MAIN: return "MAIN";
|
||||
case Module::METADATA: return "METADATA";
|
||||
case Module::REMOTE: return "REMOTE";
|
||||
case Module::SCROBBLING: return "SCROBBLING";
|
||||
case Module::SERVICE: return "SERVICE";
|
||||
case Module::RECOMMENDATION: return "RECOMMENDATION";
|
||||
case Module::TRANSCODE: return "TRANSCODE";
|
||||
|
||||
@@ -78,8 +78,8 @@ getLastWriteTime(const std::filesystem::path& file)
|
||||
return Wt::WDateTime::fromTime_t(sb.st_mtime);
|
||||
}
|
||||
|
||||
void
|
||||
exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb)
|
||||
bool
|
||||
exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb, const std::filesystem::path& excludeDirFileName)
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::directory_iterator itPath {directory, std::filesystem::directory_options::follow_directory_symlink, ec};
|
||||
@@ -87,7 +87,18 @@ exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool
|
||||
if (ec)
|
||||
{
|
||||
cb(ec, directory);
|
||||
return;
|
||||
return true; // try to continue exploring anyway
|
||||
}
|
||||
|
||||
if (!excludeDirFileName.empty())
|
||||
{
|
||||
const std::filesystem::path excludePath {directory / excludeDirFileName};
|
||||
|
||||
if (std::filesystem::exists(excludePath, ec))
|
||||
{
|
||||
LMS_LOG(DBUPDATER, DEBUG) << "Found '" << excludePath.string() << "': skipping directory";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::directory_iterator itEnd;
|
||||
@@ -108,16 +119,18 @@ exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool
|
||||
else if (std::filesystem::is_directory(*itPath, ec))
|
||||
{
|
||||
if (!ec)
|
||||
exploreFilesRecursive(*itPath, cb);
|
||||
continueExploring = exploreFilesRecursive(*itPath, cb, excludeDirFileName);
|
||||
else
|
||||
continueExploring = cb(ec, *itPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!continueExploring)
|
||||
break;
|
||||
return false;
|
||||
|
||||
itPath.increment(ec);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ enum class Module
|
||||
MAIN,
|
||||
METADATA,
|
||||
REMOTE,
|
||||
SCROBBLING,
|
||||
SERVICE,
|
||||
RECOMMENDATION,
|
||||
TRANSCODE,
|
||||
|
||||
@@ -35,7 +35,8 @@ bool ensureDirectory(const std::filesystem::path& dir);
|
||||
// Get the last write time since Epoch
|
||||
Wt::WDateTime getLastWriteTime(const std::filesystem::path& dir);
|
||||
|
||||
void exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb);
|
||||
// returns false if aborted by user
|
||||
bool exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb, const std::filesystem::path& excludeDirFileName = {});
|
||||
|
||||
namespace std
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ add_executable(lms
|
||||
main.cpp
|
||||
ui/Auth.cpp
|
||||
ui/LmsApplication.cpp
|
||||
ui/LmsApplicationGroup.cpp
|
||||
ui/LmsApplicationManager.cpp
|
||||
ui/LmsTheme.cpp
|
||||
ui/MediaPlayer.cpp
|
||||
ui/PlayQueue.cpp
|
||||
@@ -19,6 +19,7 @@ add_executable(lms
|
||||
ui/common/LoginNameValidator.cpp
|
||||
ui/common/MandatoryValidator.cpp
|
||||
ui/common/PasswordValidator.cpp
|
||||
ui/common/UUIDValidator.cpp
|
||||
ui/explore/ArtistListHelpers.cpp
|
||||
ui/explore/ArtistView.cpp
|
||||
ui/explore/ArtistsView.cpp
|
||||
@@ -49,6 +50,7 @@ target_link_libraries(lms PRIVATE
|
||||
lmsdatabase
|
||||
lmsrecommendation
|
||||
lmsscanner
|
||||
lmsscrobbling
|
||||
lmssubsonic
|
||||
lmsutils
|
||||
Wt::Wt
|
||||
|
||||
+20
-6
@@ -33,7 +33,9 @@
|
||||
#include "scanner/IScanner.hpp"
|
||||
#include "recommendation/IEngine.hpp"
|
||||
#include "subsonic/SubsonicResource.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "ui/LmsApplication.hpp"
|
||||
#include "ui/LmsApplicationManager.hpp"
|
||||
#include "utils/IChildProcessManager.hpp"
|
||||
#include "utils/IConfig.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
@@ -124,9 +126,19 @@ static
|
||||
void
|
||||
proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server)
|
||||
{
|
||||
auto postAll {[](Wt::WServer& server, std::function<void()> cb)
|
||||
{
|
||||
server.postAll([cb = std::move(cb)]
|
||||
{
|
||||
// may be nullptr, see https://redmine.webtoolkit.eu/issues/8202
|
||||
if (LmsApp)
|
||||
cb();
|
||||
});
|
||||
}};
|
||||
|
||||
scanner.getEvents().scanStarted.connect([&]
|
||||
{
|
||||
server.postAll([]
|
||||
postAll(server, []
|
||||
{
|
||||
LmsApp->getScannerEvents().scanStarted.emit();
|
||||
LmsApp->triggerUpdate();
|
||||
@@ -135,7 +147,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server)
|
||||
|
||||
scanner.getEvents().scanComplete.connect([&] (const Scanner::ScanStats& stats)
|
||||
{
|
||||
server.postAll([=]
|
||||
postAll(server, [=]
|
||||
{
|
||||
LmsApp->getScannerEvents().scanComplete.emit(stats);
|
||||
LmsApp->triggerUpdate();
|
||||
@@ -144,7 +156,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server)
|
||||
|
||||
scanner.getEvents().scanInProgress.connect([&] (const Scanner::ScanStepStats& stats)
|
||||
{
|
||||
server.postAll([=]
|
||||
postAll(server, [=]
|
||||
{
|
||||
LmsApp->getScannerEvents().scanInProgress.emit(stats);
|
||||
LmsApp->triggerUpdate();
|
||||
@@ -153,7 +165,7 @@ proxyScannerEventsToApplication(Scanner::IScanner& scanner, Wt::WServer& server)
|
||||
|
||||
scanner.getEvents().scanScheduled.connect([&] (const Wt::WDateTime dateTime)
|
||||
{
|
||||
server.postAll([=]
|
||||
postAll(server, [=]
|
||||
{
|
||||
LmsApp->getScannerEvents().scanScheduled.emit(dateTime);
|
||||
LmsApp->triggerUpdate();
|
||||
@@ -211,7 +223,7 @@ int main(int argc, char* argv[])
|
||||
session.optimize();
|
||||
}
|
||||
|
||||
UserInterface::LmsApplicationGroupContainer appGroups;
|
||||
UserInterface::LmsApplicationManager appManager;
|
||||
|
||||
// Service initialization order is important (reverse-order for deinit)
|
||||
Service<IChildProcessManager> childProcessManagerService {createChildProcessManager()};
|
||||
@@ -248,6 +260,8 @@ int main(int argc, char* argv[])
|
||||
coverArtService->flushCache();
|
||||
});
|
||||
|
||||
Service<Scrobbling::IScrobbling> scrobblingService {Scrobbling::createScrobbling(database)};
|
||||
|
||||
API::Subsonic::SubsonicResource subsonicResource {database};
|
||||
|
||||
// bind API resources
|
||||
@@ -258,7 +272,7 @@ int main(int argc, char* argv[])
|
||||
server.addEntryPoint(Wt::EntryPointType::Application,
|
||||
[&](const Wt::WEnvironment &env)
|
||||
{
|
||||
return UserInterface::LmsApplication::create(env, database, appGroups);
|
||||
return UserInterface::LmsApplication::create(env, database, appManager);
|
||||
});
|
||||
|
||||
proxyScannerEventsToApplication(*scannerService, server);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <iomanip>
|
||||
|
||||
#include <Wt/WEnvironment.h>
|
||||
#include <Wt/WFormModel.h>
|
||||
#include <Wt/WLineEdit.h>
|
||||
#include <Wt/WCheckBox.h>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include "database/Release.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
#include "utils/String.hpp"
|
||||
@@ -53,6 +54,7 @@
|
||||
#include "resource/CoverResource.hpp"
|
||||
#include "Auth.hpp"
|
||||
#include "LmsApplicationException.hpp"
|
||||
#include "LmsApplicationManager.hpp"
|
||||
#include "LmsTheme.hpp"
|
||||
#include "MediaPlayer.hpp"
|
||||
#include "PlayQueue.hpp"
|
||||
@@ -63,7 +65,7 @@ namespace UserInterface {
|
||||
static constexpr const char* defaultPath {"/releases"};
|
||||
|
||||
std::unique_ptr<Wt::WApplication>
|
||||
LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups)
|
||||
LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager)
|
||||
{
|
||||
if (auto *authEnvService {Service<::Auth::IEnvService>::get()})
|
||||
{
|
||||
@@ -75,10 +77,10 @@ LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplica
|
||||
return std::make_unique<Wt::WApplication>(env);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appGroups, checkResult.userId);
|
||||
return std::make_unique<LmsApplication>(env, db, appManager, checkResult.userId);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appGroups);
|
||||
return std::make_unique<LmsApplication>(env, db, appManager);
|
||||
}
|
||||
|
||||
LmsApplication*
|
||||
@@ -102,6 +104,12 @@ LmsApplication::getUser()
|
||||
return Database::User::getById(getDbSession(), _authenticatedUser->userId);
|
||||
}
|
||||
|
||||
Database::IdType
|
||||
LmsApplication::getUserId()
|
||||
{
|
||||
return _authenticatedUser->userId;
|
||||
}
|
||||
|
||||
bool
|
||||
LmsApplication::isUserAuthStrong() const
|
||||
{
|
||||
@@ -134,11 +142,11 @@ LmsApplication::getUserLoginName()
|
||||
|
||||
LmsApplication::LmsApplication(const Wt::WEnvironment& env,
|
||||
Database::Db& db,
|
||||
LmsApplicationGroupContainer& appGroups,
|
||||
LmsApplicationManager& appManager,
|
||||
std::optional<Database::IdType> userId)
|
||||
: Wt::WApplication {env}
|
||||
, _db {db}
|
||||
, _appGroups {appGroups}
|
||||
, _appManager {appManager}
|
||||
, _authenticatedUser {userId ? std::make_optional<UserAuthInfo>(UserAuthInfo {*userId, false}) : std::nullopt}
|
||||
{
|
||||
try
|
||||
@@ -261,16 +269,7 @@ void
|
||||
LmsApplication::finalize()
|
||||
{
|
||||
if (_authenticatedUser)
|
||||
{
|
||||
LmsApplicationInfo info = LmsApplicationInfo::fromEnvironment(environment());
|
||||
|
||||
getApplicationGroup().postOthers([info]
|
||||
{
|
||||
LmsApp->getEvents().appClosed(info);
|
||||
});
|
||||
|
||||
getApplicationGroup().leave();
|
||||
}
|
||||
_appManager.unregisterApplication(*this);
|
||||
|
||||
preQuit().emit();
|
||||
}
|
||||
@@ -427,12 +426,6 @@ handlePathChange(Wt::WStackedWidget& stack, bool isAdmin)
|
||||
wApp->setInternalPath(defaultPath, true);
|
||||
}
|
||||
|
||||
LmsApplicationGroup&
|
||||
LmsApplication::getApplicationGroup()
|
||||
{
|
||||
return _appGroups.get(_authenticatedUser->userId);
|
||||
}
|
||||
|
||||
void
|
||||
LmsApplication::logoutUser()
|
||||
{
|
||||
@@ -451,14 +444,19 @@ LmsApplication::onUserLoggedIn()
|
||||
setTheme();
|
||||
root()->clear();
|
||||
|
||||
const LmsApplicationInfo info {LmsApplicationInfo::fromEnvironment(environment())};
|
||||
|
||||
LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent();
|
||||
getApplicationGroup().join(info);
|
||||
|
||||
getApplicationGroup().postOthers([info]
|
||||
_appManager.registerApplication(*this);
|
||||
_appManager.applicationRegistered.connect(this, [this] (LmsApplication& otherApplication)
|
||||
{
|
||||
LmsApp->getEvents().appOpen(info);
|
||||
// Only one active session by user
|
||||
if (otherApplication.getUserId() == getUserId())
|
||||
{
|
||||
if (!LmsApp->isUserDemo())
|
||||
{
|
||||
quit(Wt::WString::tr("Lms.quit-other-session"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createHome();
|
||||
@@ -547,6 +545,21 @@ LmsApplication::createHome()
|
||||
{
|
||||
_playQueue->playPrevious();
|
||||
});
|
||||
|
||||
_mediaPlayer->scrobbleListenNow.connect([this](Database::IdType trackId)
|
||||
{
|
||||
LMS_LOG(UI, DEBUG) << "Received ScrobbleListenNow from player for trackId = " << trackId;
|
||||
const Scrobbling::Listen listen {getUserId(), trackId};
|
||||
Service<Scrobbling::IScrobbling>::get()->listenStarted(listen);
|
||||
});
|
||||
_mediaPlayer->scrobbleListenFinished.connect([this](Database::IdType trackId, unsigned durationMs)
|
||||
{
|
||||
LMS_LOG(UI, DEBUG) << "Received ScrobbleListenFinished from player for trackId = " << trackId << ", duration = " << (durationMs / 1000) << "s";
|
||||
const std::chrono::milliseconds duration {durationMs};
|
||||
const Scrobbling::Listen listen {getUserId(), trackId};
|
||||
Service<Scrobbling::IScrobbling>::get()->listenFinished(listen, std::chrono::duration_cast<std::chrono::seconds>(duration));
|
||||
});
|
||||
|
||||
_mediaPlayer->playbackEnded.connect([this]
|
||||
{
|
||||
_playQueue->playNext();
|
||||
@@ -576,16 +589,6 @@ LmsApplication::createHome()
|
||||
});
|
||||
}
|
||||
|
||||
// Events from Application group
|
||||
_events.appOpen.connect([=]
|
||||
{
|
||||
// Only one active session by user
|
||||
if (!LmsApp->isUserDemo())
|
||||
{
|
||||
quit(Wt::WString::tr("Lms.quit-other-session"));
|
||||
}
|
||||
});
|
||||
|
||||
internalPathChanged().connect([=]
|
||||
{
|
||||
handlePathChange(*mainStack, isUserAdmin());
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
|
||||
#include "scanner/ScannerEvents.hpp"
|
||||
|
||||
#include "LmsApplicationGroup.hpp"
|
||||
|
||||
namespace Database
|
||||
{
|
||||
class Artist;
|
||||
@@ -47,23 +45,16 @@ class CoverResource;
|
||||
class LmsApplicationException;
|
||||
class MediaPlayer;
|
||||
class PlayQueue;
|
||||
|
||||
// Events that can be listen from anywhere in the application
|
||||
struct Events
|
||||
{
|
||||
// Events relative to group
|
||||
Wt::Signal<LmsApplicationInfo> appOpen;
|
||||
Wt::Signal<LmsApplicationInfo> appClosed;
|
||||
};
|
||||
class LmsApplicationManager;
|
||||
|
||||
class LmsApplication : public Wt::WApplication
|
||||
{
|
||||
public:
|
||||
|
||||
LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups, std::optional<Database::IdType> userId = std::nullopt);
|
||||
LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager, std::optional<Database::IdType> userId = std::nullopt);
|
||||
~LmsApplication();
|
||||
|
||||
static std::unique_ptr<Wt::WApplication> create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationGroupContainer& appGroups);
|
||||
static std::unique_ptr<Wt::WApplication> create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager);
|
||||
static LmsApplication* instance();
|
||||
|
||||
|
||||
@@ -71,13 +62,14 @@ class LmsApplication : public Wt::WApplication
|
||||
std::shared_ptr<CoverResource> getCoverResource() { return _coverResource; }
|
||||
Database::Session& getDbSession(); // always thread safe
|
||||
|
||||
Wt::Dbo::ptr<Database::User> getUser();
|
||||
Wt::Dbo::ptr<Database::User> getUser();
|
||||
Database::IdType getUserId();
|
||||
bool isUserAuthStrong() const; // user must be logged in prior this call
|
||||
bool isUserAdmin(); // user must be logged in prior this call
|
||||
bool isUserDemo(); // user must be logged in prior this call
|
||||
std::string getUserLoginName(); // user must be logged in prior this call
|
||||
|
||||
Events& getEvents() { return _events; }
|
||||
// Proxified scanner events
|
||||
Scanner::Events& getScannerEvents() { return _scannerEvents; }
|
||||
|
||||
// Utils
|
||||
@@ -113,8 +105,6 @@ class LmsApplication : public Wt::WApplication
|
||||
void handleException(LmsApplicationException& e);
|
||||
void goHomeAndQuit();
|
||||
|
||||
LmsApplicationGroup& getApplicationGroup();
|
||||
|
||||
// Signal slots
|
||||
void logoutUser();
|
||||
void onUserLoggedIn();
|
||||
@@ -126,8 +116,7 @@ class LmsApplication : public Wt::WApplication
|
||||
|
||||
Database::Db& _db;
|
||||
Wt::Signal<> _preQuit;
|
||||
LmsApplicationGroupContainer& _appGroups;
|
||||
Events _events;
|
||||
LmsApplicationManager& _appManager;
|
||||
Scanner::Events _scannerEvents;
|
||||
struct UserAuthInfo
|
||||
{
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "LmsApplicationGroup.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <Wt/WServer.h>
|
||||
#include <Wt/WApplication.h>
|
||||
|
||||
|
||||
namespace UserInterface {
|
||||
|
||||
LmsApplicationInfo
|
||||
LmsApplicationInfo::fromEnvironment(const Wt::WEnvironment& env)
|
||||
{
|
||||
LmsApplicationInfo info = {env.agent()};
|
||||
return info;
|
||||
}
|
||||
|
||||
void
|
||||
LmsApplicationGroup::join(LmsApplicationInfo info)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock {_mutex};
|
||||
|
||||
_apps.emplace(wApp->sessionId(), std::move(info));
|
||||
}
|
||||
|
||||
void
|
||||
LmsApplicationGroup::leave()
|
||||
{
|
||||
std::unique_lock<std::mutex> lock {_mutex};
|
||||
|
||||
_apps.erase(wApp->sessionId());
|
||||
}
|
||||
|
||||
std::vector<std::string>
|
||||
LmsApplicationGroup::getOtherSessionIds() const
|
||||
{
|
||||
std::vector<std::string> res;
|
||||
|
||||
std::unique_lock<std::mutex> lock {_mutex};
|
||||
for (auto const& app : _apps)
|
||||
{
|
||||
if (app.first != wApp->sessionId())
|
||||
res.push_back(app.first);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
void
|
||||
LmsApplicationGroup::postOthers(std::function<void()> func) const
|
||||
{
|
||||
for (auto const& sessionId : getOtherSessionIds())
|
||||
{
|
||||
Wt::WServer::instance()->post(sessionId, [=]
|
||||
{
|
||||
func();
|
||||
wApp->triggerUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
LmsApplicationGroup&
|
||||
LmsApplicationGroupContainer::get(Database::IdType userId)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock {_mutex};
|
||||
|
||||
return _apps[userId];
|
||||
}
|
||||
|
||||
} // UserInterface
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
#include <Wt/WSignal.h>
|
||||
#include <Wt/WEnvironment.h>
|
||||
|
||||
#include "database/Types.hpp"
|
||||
|
||||
namespace UserInterface {
|
||||
|
||||
|
||||
struct LmsApplicationInfo
|
||||
{
|
||||
Wt::UserAgent userAgent;
|
||||
|
||||
static LmsApplicationInfo fromEnvironment(const Wt::WEnvironment& env);
|
||||
};
|
||||
|
||||
// LmsApplication instances are grouped by User
|
||||
class LmsApplicationGroup
|
||||
{
|
||||
public:
|
||||
void join(LmsApplicationInfo info);
|
||||
void leave();
|
||||
|
||||
void postOthers(std::function<void()> func) const;
|
||||
|
||||
private:
|
||||
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
std::vector<std::string> getOtherSessionIds() const;
|
||||
|
||||
std::map<std::string, LmsApplicationInfo> _apps;
|
||||
};
|
||||
|
||||
class LmsApplicationGroupContainer
|
||||
{
|
||||
public:
|
||||
LmsApplicationGroup& get(Database::IdType userId);
|
||||
|
||||
private:
|
||||
std::map<Database::IdType /* userId */, LmsApplicationGroup> _apps;
|
||||
std::mutex _mutex;
|
||||
};
|
||||
|
||||
} // UserInterface
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "LmsApplicationManager.hpp"
|
||||
|
||||
#include "LmsApplication.hpp"
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
void
|
||||
LmsApplicationManager::registerApplication(LmsApplication& application)
|
||||
{
|
||||
{
|
||||
std::scoped_lock lock {_mutex};
|
||||
m_applications[application.getUserId()].insert(&application);
|
||||
}
|
||||
|
||||
applicationRegistered.emit(application);
|
||||
}
|
||||
|
||||
void
|
||||
LmsApplicationManager::unregisterApplication(LmsApplication& application)
|
||||
{
|
||||
{
|
||||
std::scoped_lock lock {_mutex};
|
||||
m_applications[application.getUserId()].erase(&application);
|
||||
}
|
||||
|
||||
applicationUnregistered.emit(application);
|
||||
}
|
||||
|
||||
} // UserInterface
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <unordered_set>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <Wt/WSignal.h>
|
||||
|
||||
#include "database/Types.hpp"
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
class LmsApplication;
|
||||
class LmsApplicationManager
|
||||
{
|
||||
public:
|
||||
Wt::Signal<LmsApplication&> applicationRegistered;
|
||||
Wt::Signal<LmsApplication&> applicationUnregistered;
|
||||
|
||||
private:
|
||||
friend class LmsApplication;
|
||||
|
||||
void registerApplication(LmsApplication& application);
|
||||
void unregisterApplication(LmsApplication& application);
|
||||
|
||||
std::mutex _mutex;
|
||||
std::unordered_map<Database::IdType /* user */, std::unordered_set<LmsApplication*>> m_applications;
|
||||
};
|
||||
} // UserInterface
|
||||
@@ -193,9 +193,11 @@ static MediaPlayer::Settings settingsfromJSString(const std::string& strSettings
|
||||
|
||||
MediaPlayer::MediaPlayer()
|
||||
: Wt::WTemplate {Wt::WString::tr("Lms.MediaPlayer.template")}
|
||||
, playbackEnded {this, "playbackEnded"}
|
||||
, playPrevious {this, "playPrevious"}
|
||||
, playNext {this, "playNext"}
|
||||
, scrobbleListenNow {this, "scrobbleListenNow"}
|
||||
, scrobbleListenFinished {this, "scrobbleListenFinished"}
|
||||
, playbackEnded {this, "playbackEnded"}
|
||||
, _settingsLoaded {this, "settingsLoaded"}
|
||||
{
|
||||
addFunction("tr", &Wt::WTemplate::Functions::tr);
|
||||
@@ -250,6 +252,7 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain)
|
||||
|
||||
oss
|
||||
<< "var params = {"
|
||||
<< " trackId :\"" << trackId << "\","
|
||||
<< " nativeResource: \"" << nativeResource << "\","
|
||||
<< " transcodeResource: \"" << transcodeResource << "\","
|
||||
<< " duration: " << std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count() << ","
|
||||
@@ -295,15 +298,6 @@ MediaPlayer::loadTrack(Database::IdType trackId, bool play, float replayGain)
|
||||
LMS_LOG(UI, DEBUG) << "Running js = '" << oss.str() << "'";
|
||||
wApp->doJavaScript(oss.str());
|
||||
|
||||
{
|
||||
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
|
||||
|
||||
const Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)};
|
||||
if (track)
|
||||
Database::TrackListEntry::create(LmsApp->getDbSession(), track, LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession()));
|
||||
|
||||
};
|
||||
|
||||
_trackIdLoaded = trackId;
|
||||
trackLoaded.emit(*_trackIdLoaded);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
|
||||
#include <Wt/WAnchor.h>
|
||||
#include <Wt/WJavaScript.h>
|
||||
#include <Wt/WTemplate.h>
|
||||
#include <Wt/WText.h>
|
||||
|
||||
#include "database/Types.hpp"
|
||||
#include "database/User.hpp"
|
||||
|
||||
namespace UserInterface {
|
||||
|
||||
@@ -101,12 +103,16 @@ class MediaPlayer : public Wt::WTemplate
|
||||
void setSettings(const Settings& settings);
|
||||
|
||||
// Signals
|
||||
Wt::JSignal<> playbackEnded;
|
||||
Wt::JSignal<> playPrevious;
|
||||
Wt::JSignal<> playNext;
|
||||
Wt::Signal<Database::IdType> trackLoaded;
|
||||
Wt::Signal<> settingsLoaded;
|
||||
|
||||
Wt::JSignal<Database::IdType> scrobbleListenNow;
|
||||
Wt::JSignal<Database::IdType, unsigned /* ms */> scrobbleListenFinished;
|
||||
|
||||
Wt::JSignal<> playbackEnded;
|
||||
|
||||
private:
|
||||
std::unique_ptr<AudioFileResource> _audioFileResource;
|
||||
std::unique_ptr<AudioTranscodeResource> _audioTranscodeResource;
|
||||
@@ -115,9 +121,10 @@ class MediaPlayer : public Wt::WTemplate
|
||||
std::optional<Settings> _settings;
|
||||
|
||||
Wt::JSignal<std::string> _settingsLoaded;
|
||||
Wt::WText* _title;
|
||||
Wt::WAnchor* _release;
|
||||
Wt::WAnchor* _artist;
|
||||
|
||||
Wt::WText* _title {};
|
||||
Wt::WAnchor* _release {};
|
||||
Wt::WAnchor* _artist {};
|
||||
};
|
||||
|
||||
} // namespace UserInterface
|
||||
|
||||
+89
-32
@@ -31,6 +31,7 @@
|
||||
|
||||
#include "common/PasswordValidator.hpp"
|
||||
#include "common/MandatoryValidator.hpp"
|
||||
#include "common/UUIDValidator.hpp"
|
||||
#include "common/ValueStringModel.hpp"
|
||||
|
||||
#include "auth/IPasswordService.hpp"
|
||||
@@ -62,12 +63,15 @@ class SettingsModel : public Wt::WFormModel
|
||||
static inline const Field SubsonicTranscodeEnableField {"subsonic-transcode-enable"};
|
||||
static inline const Field SubsonicTranscodeFormatField {"subsonic-transcode-format"};
|
||||
static inline const Field SubsonicTranscodeBitrateField {"subsonic-transcode-bitrate"};
|
||||
static inline const Field ScrobblerField {"scrobbler"};
|
||||
static inline const Field ListenBrainzTokenField {"listenbrainz-token"};
|
||||
static inline const Field PasswordOldField {"password-old"};
|
||||
static inline const Field PasswordField {"password"};
|
||||
static inline const Field PasswordConfirmField {"password-confirm"};
|
||||
|
||||
using TranscodeModeModel = ValueStringModel<MediaPlayer::Settings::Transcode::Mode>;
|
||||
using ReplayGainModeModel = ValueStringModel<MediaPlayer::Settings::ReplayGain::Mode>;
|
||||
using ScrobblerModel = ValueStringModel<Scrobbler>;
|
||||
|
||||
SettingsModel(::Auth::IPasswordService* authPasswordService, bool withOldPassword)
|
||||
: _authPasswordService {authPasswordService}
|
||||
@@ -85,6 +89,9 @@ class SettingsModel : public Wt::WFormModel
|
||||
addField(SubsonicTranscodeEnableField);
|
||||
addField(SubsonicTranscodeBitrateField);
|
||||
addField(SubsonicTranscodeFormatField);
|
||||
addField(ScrobblerField);
|
||||
addField(ListenBrainzTokenField);
|
||||
setValidator(ListenBrainzTokenField, createUUIDValidator());
|
||||
|
||||
if (_authPasswordService)
|
||||
{
|
||||
@@ -103,7 +110,6 @@ class SettingsModel : public Wt::WFormModel
|
||||
setValidator(TranscodeBitrateField, createMandatoryValidator());
|
||||
setValidator(TranscodeFormatField, createMandatoryValidator());
|
||||
setValidator(ReplayGainModeField, createMandatoryValidator());
|
||||
|
||||
auto createPreAmpValidator = []
|
||||
{
|
||||
auto preampGainValidator {std::make_unique<Wt::WDoubleValidator>()};
|
||||
@@ -119,11 +125,12 @@ class SettingsModel : public Wt::WFormModel
|
||||
loadData();
|
||||
}
|
||||
|
||||
std::shared_ptr<TranscodeModeModel> getTranscodeModeModel() { return _transcodeModeModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getTranscodeBitrateModel() { return _transcodeBitrateModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getTranscodeFormatModel() { return _transcodeFormatModel; }
|
||||
std::shared_ptr<ReplayGainModeModel> getReplayGainModeModel() { return _replayGainModeModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; }
|
||||
std::shared_ptr<TranscodeModeModel> getTranscodeModeModel() { return _transcodeModeModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getTranscodeBitrateModel() { return _transcodeBitrateModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getTranscodeFormatModel() { return _transcodeFormatModel; }
|
||||
std::shared_ptr<ReplayGainModeModel> getReplayGainModeModel() { return _replayGainModeModel; }
|
||||
std::shared_ptr<Wt::WAbstractItemModel> getSubsonicArtistListModeModel() { return _subsonicArtistListModeModel; }
|
||||
std::shared_ptr<ScrobblerModel> getScrobblerModel() { return _scrobblerModel; }
|
||||
|
||||
void saveData()
|
||||
{
|
||||
@@ -174,11 +181,19 @@ class SettingsModel : public Wt::WFormModel
|
||||
auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromString(valueText(SubsonicTranscodeFormatField))};
|
||||
if (subsonicTranscodeFormatRow)
|
||||
user.modify()->setSubsonicTranscodeFormat(_transcodeFormatModel->getValue(*subsonicTranscodeFormatRow));
|
||||
|
||||
auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))};
|
||||
if (subsonicArtistListModeRow)
|
||||
user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow));
|
||||
|
||||
}
|
||||
|
||||
auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromString(valueText(SubsonicArtistListModeField))};
|
||||
if (subsonicArtistListModeRow)
|
||||
user.modify()->setSubsonicArtistListMode(_subsonicArtistListModeModel->getValue(*subsonicArtistListModeRow));
|
||||
{
|
||||
if (auto scrobblerRow {_scrobblerModel->getRowFromString(valueText(ScrobblerField))})
|
||||
user.modify()->setScrobbler(_scrobblerModel->getValue(*scrobblerRow));
|
||||
|
||||
user.modify()->setListenBrainzToken(UUID::fromString(Wt::asString(value(ListenBrainzTokenField)).toUTF8()));
|
||||
}
|
||||
|
||||
if (_authPasswordService && !valueText(PasswordField).empty())
|
||||
{
|
||||
@@ -210,6 +225,12 @@ class SettingsModel : public Wt::WFormModel
|
||||
if (transcodeBitrateRow)
|
||||
setValue(TranscodeBitrateField, _transcodeBitrateModel->getString(*transcodeBitrateRow));
|
||||
|
||||
{
|
||||
const bool usesTranscode {settings.transcode.mode != MediaPlayer::Settings::Transcode::Mode::Never};
|
||||
setReadOnly(SettingsModel::TranscodeFormatField, !usesTranscode);
|
||||
setReadOnly(SettingsModel::TranscodeBitrateField, !usesTranscode);
|
||||
}
|
||||
|
||||
auto replayGainModeRow {_replayGainModeModel->getRowFromValue(settings.replayGain.mode)};
|
||||
if (replayGainModeRow)
|
||||
setValue(ReplayGainModeField, _replayGainModeModel->getString(*replayGainModeRow));
|
||||
@@ -219,27 +240,45 @@ class SettingsModel : public Wt::WFormModel
|
||||
}
|
||||
|
||||
setValue(SubsonicTranscodeEnableField, LmsApp->getUser()->getSubsonicTranscodeEnable());
|
||||
if (!LmsApp->getUser()->getSubsonicTranscodeEnable())
|
||||
{
|
||||
setReadOnly(SubsonicTranscodeFormatField, true);
|
||||
setReadOnly(SubsonicTranscodeBitrateField, true);
|
||||
const bool usesTranscode {LmsApp->getUser()->getSubsonicTranscodeEnable()};
|
||||
setReadOnly(SubsonicTranscodeFormatField, !usesTranscode);
|
||||
setReadOnly(SubsonicTranscodeBitrateField, !usesTranscode);
|
||||
}
|
||||
|
||||
auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())};
|
||||
if (subsonicTranscodeBitrateRow)
|
||||
setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow));
|
||||
{
|
||||
auto subsonicTranscodeBitrateRow {_transcodeBitrateModel->getRowFromValue(user->getSubsonicTranscodeBitrate())};
|
||||
if (subsonicTranscodeBitrateRow)
|
||||
setValue(SubsonicTranscodeBitrateField, _transcodeBitrateModel->getString(*subsonicTranscodeBitrateRow));
|
||||
|
||||
auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())};
|
||||
if (subsonicTranscodeFormatRow)
|
||||
setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow));
|
||||
auto subsonicTranscodeFormatRow {_transcodeFormatModel->getRowFromValue(user->getSubsonicTranscodeFormat())};
|
||||
if (subsonicTranscodeFormatRow)
|
||||
setValue(SubsonicTranscodeFormatField, _transcodeFormatModel->getString(*subsonicTranscodeFormatRow));
|
||||
|
||||
auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())};
|
||||
if (subsonicArtistListModeRow)
|
||||
setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow));
|
||||
auto subsonicArtistListModeRow {_subsonicArtistListModeModel->getRowFromValue(user->getSubsonicArtistListMode())};
|
||||
if (subsonicArtistListModeRow)
|
||||
setValue(SubsonicArtistListModeField, _subsonicArtistListModeModel->getString(*subsonicArtistListModeRow));
|
||||
}
|
||||
|
||||
{
|
||||
if (auto scrobblerRow {_scrobblerModel->getRowFromValue(user->getScrobbler())})
|
||||
setValue(ScrobblerField, _scrobblerModel->getString(*scrobblerRow));
|
||||
|
||||
if (auto listenBrainzToken {user->getListenBrainzToken()})
|
||||
{
|
||||
LMS_LOG(UI, DEBUG) << "Read listenBrainzToken! value = " << listenBrainzToken->getAsString();
|
||||
setValue(ListenBrainzTokenField, Wt::WString::fromUTF8( std::string {listenBrainzToken->getAsString()}));
|
||||
}
|
||||
|
||||
{
|
||||
const bool usesListenBrainz {user->getScrobbler() == Scrobbler::ListenBrainz};
|
||||
setReadOnly(SettingsModel::ListenBrainzTokenField, !usesListenBrainz);
|
||||
validator(SettingsModel::ListenBrainzTokenField)->setMandatory(usesListenBrainz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
bool validateField(Field field)
|
||||
{
|
||||
Wt::WString error;
|
||||
@@ -280,7 +319,6 @@ class SettingsModel : public Wt::WFormModel
|
||||
|
||||
void initializeModels()
|
||||
{
|
||||
|
||||
_transcodeModeModel = std::make_shared<TranscodeModeModel>();
|
||||
_transcodeModeModel->add(Wt::WString::tr("Lms.Settings.transcode-mode.always"), MediaPlayer::Settings::Transcode::Mode::Always);
|
||||
_transcodeModeModel->add(Wt::WString::tr("Lms.Settings.transcode-mode.never"), MediaPlayer::Settings::Transcode::Mode::Never);
|
||||
@@ -309,16 +347,21 @@ class SettingsModel : public Wt::WFormModel
|
||||
_subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.all-artists"), User::SubsonicArtistListMode::AllArtists);
|
||||
_subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.release-artists"), User::SubsonicArtistListMode::ReleaseArtists);
|
||||
_subsonicArtistListModeModel->add(Wt::WString::tr("Lms.Settings.subsonic-artist-list-mode.track-artists"), User::SubsonicArtistListMode::TrackArtists);
|
||||
|
||||
_scrobblerModel = std::make_shared<ValueStringModel<Scrobbler>>();
|
||||
_scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.internal"), Scrobbler::Internal);
|
||||
_scrobblerModel->add(Wt::WString::tr("Lms.Settings.scrobbling.scrobbler.listenbrainz"), Scrobbler::ListenBrainz);
|
||||
}
|
||||
|
||||
::Auth::IPasswordService* _authPasswordService {};
|
||||
bool _withOldPassword {};
|
||||
|
||||
std::shared_ptr<TranscodeModeModel> _transcodeModeModel;
|
||||
std::shared_ptr<TranscodeModeModel> _transcodeModeModel;
|
||||
std::shared_ptr<ValueStringModel<Bitrate>> _transcodeBitrateModel;
|
||||
std::shared_ptr<ValueStringModel<AudioFormat>> _transcodeFormatModel;
|
||||
std::shared_ptr<ValueStringModel<AudioFormat>> _transcodeFormatModel;
|
||||
std::shared_ptr<ReplayGainModeModel> _replayGainModeModel;
|
||||
std::shared_ptr<ValueStringModel<User::SubsonicArtistListMode>> _subsonicArtistListModeModel;
|
||||
std::shared_ptr<ScrobblerModel> _scrobblerModel;
|
||||
};
|
||||
|
||||
SettingsView::SettingsView()
|
||||
@@ -416,11 +459,6 @@ SettingsView::refreshView()
|
||||
t->updateModel(model.get());
|
||||
t->updateView(model.get());
|
||||
});
|
||||
if (LmsApp->getMediaPlayer().getSettings()->transcode.mode == MediaPlayer::Settings::Transcode::Mode::Never)
|
||||
{
|
||||
model->setReadOnly(SettingsModel::TranscodeFormatField, true);
|
||||
model->setReadOnly(SettingsModel::TranscodeBitrateField, true);
|
||||
}
|
||||
|
||||
// Replay gain mode
|
||||
auto replayGainMode {std::make_unique<Wt::WComboBox>()};
|
||||
@@ -473,11 +511,11 @@ SettingsView::refreshView()
|
||||
t->setFormWidget(SettingsModel::SubsonicTranscodeBitrateField, std::move(transcodeBitrate));
|
||||
|
||||
// Artist list mode
|
||||
auto artistListMode = std::make_unique<Wt::WComboBox>();
|
||||
auto artistListMode {std::make_unique<Wt::WComboBox>()};
|
||||
artistListMode->setModel(model->getSubsonicArtistListModeModel());
|
||||
t->setFormWidget(SettingsModel::SubsonicArtistListModeField, std::move(artistListMode));
|
||||
|
||||
transcodeRaw->changed().connect([=]()
|
||||
transcodeRaw->changed().connect([=]
|
||||
{
|
||||
const bool enable {transcodeRaw->checkState() == Wt::CheckState::Checked};
|
||||
model->setReadOnly(SettingsModel::SubsonicTranscodeFormatField, !enable);
|
||||
@@ -487,6 +525,25 @@ SettingsView::refreshView()
|
||||
});
|
||||
}
|
||||
|
||||
// Scrobbling
|
||||
{
|
||||
auto scrobbler {std::make_unique<Wt::WComboBox>()};
|
||||
scrobbler->setModel(model->getScrobblerModel());
|
||||
auto* scrobblerRaw {scrobbler.get()};
|
||||
t->setFormWidget(SettingsModel::ScrobblerField, std::move(scrobbler));
|
||||
|
||||
auto listenbrainzToken {std::make_unique<Wt::WLineEdit>()};
|
||||
t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken));
|
||||
scrobblerRaw->activated().connect([=](int row)
|
||||
{
|
||||
const bool enable {model->getScrobblerModel()->getValue(row) == Scrobbler::ListenBrainz};
|
||||
model->setReadOnly(SettingsModel::ListenBrainzTokenField, !enable);
|
||||
model->validator(SettingsModel::ListenBrainzTokenField)->setMandatory(enable);
|
||||
t->updateModel(model.get());
|
||||
t->updateView(model.get());
|
||||
});
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Wt::WPushButton *saveBtn {t->bindWidget("apply-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.apply")))};
|
||||
Wt::WPushButton *discardBtn {t->bindWidget("discard-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.discard")))};
|
||||
|
||||
@@ -95,8 +95,8 @@ class ReportResource : public Wt::WResource
|
||||
continue;
|
||||
|
||||
response.out() << track->getPath().string();
|
||||
if (auto mbid {track->getMBID()})
|
||||
response.out() << " (MBID " << mbid->getAsString() << ")";
|
||||
if (auto mbid {track->getTrackMBID()})
|
||||
response.out() << " (Track MBID " << mbid->getAsString() << ")";
|
||||
|
||||
response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
#include "PasswordValidator.hpp"
|
||||
|
||||
#include <Wt/WEnvironment.h>
|
||||
|
||||
#include "auth/IPasswordService.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
#include "LmsApplication.hpp"
|
||||
@@ -68,6 +70,9 @@ namespace UserInterface
|
||||
Wt::WValidator::Result
|
||||
PasswordCheckValidator::validate(const Wt::WString& input) const
|
||||
{
|
||||
if (input.empty())
|
||||
return Wt::WValidator::validate(input);
|
||||
|
||||
const auto checkResult {Service<::Auth::IPasswordService>::get()->checkUserPassword(
|
||||
LmsApp->getDbSession(),
|
||||
boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "UUIDValidator.hpp"
|
||||
|
||||
#include <Wt/WRegExpValidator.h>
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
std::shared_ptr<Wt::WValidator>
|
||||
createUUIDValidator()
|
||||
{
|
||||
return std::make_unique<Wt::WRegExpValidator>("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}");
|
||||
}
|
||||
} // namespace UserInterface
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Wt/WValidator.h>
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
std::shared_ptr<Wt::WValidator> createUUIDValidator();
|
||||
} // namespace UserInterface
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "database/User.hpp"
|
||||
#include "database/TrackArtistLink.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
|
||||
#include "common/LoadingIndicator.hpp"
|
||||
@@ -212,15 +213,15 @@ Artists::getArtists(std::optional<Range> range, bool& moreResults)
|
||||
break;
|
||||
|
||||
case Mode::RecentlyPlayed:
|
||||
artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())
|
||||
->getArtistsReverse(_filters->getClusterIds(),
|
||||
artists = Service<Scrobbling::IScrobbling>::get()->getRecentArtists(LmsApp->getDbSession(), LmsApp->getUser(),
|
||||
_filters->getClusterIds(),
|
||||
linkType,
|
||||
range, moreResults);
|
||||
break;
|
||||
|
||||
case Mode::MostPlayed:
|
||||
artists = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())
|
||||
->getTopArtists(_filters->getClusterIds(),
|
||||
artists = Service<Scrobbling::IScrobbling>::get()->getTopArtists(LmsApp->getDbSession(), LmsApp->getUser(),
|
||||
_filters->getClusterIds(),
|
||||
linkType,
|
||||
range, moreResults);
|
||||
break;
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "database/Session.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
|
||||
@@ -205,11 +206,11 @@ Releases::getReleases(std::optional<Range> range, bool& moreResults)
|
||||
break;
|
||||
|
||||
case Mode::RecentlyPlayed:
|
||||
releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getReleasesReverse(_filters->getClusterIds(), range, moreResults);
|
||||
releases = Service<Scrobbling::IScrobbling>::get()->getRecentReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults);
|
||||
break;
|
||||
|
||||
case Mode::MostPlayed:
|
||||
releases = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopReleases(_filters->getClusterIds(), range, moreResults);
|
||||
releases = Service<Scrobbling::IScrobbling>::get()->getTopReleases(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults);
|
||||
break;
|
||||
|
||||
case Mode::RecentlyAdded:
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
#include "database/Session.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "database/TrackList.hpp"
|
||||
|
||||
#include "database/User.hpp"
|
||||
#include "scrobbling/IScrobbling.hpp"
|
||||
#include "utils/Logger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
|
||||
@@ -192,11 +193,11 @@ Tracks::getTracks(std::optional<Range> range, bool& moreResults)
|
||||
break;
|
||||
|
||||
case Mode::RecentlyPlayed:
|
||||
tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTracksReverse(_filters->getClusterIds(), range, moreResults);
|
||||
tracks = Service<Scrobbling::IScrobbling>::get()->getRecentTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults);
|
||||
break;
|
||||
|
||||
case Mode::MostPlayed:
|
||||
tracks = LmsApp->getUser()->getPlayedTrackList(LmsApp->getDbSession())->getTopTracks(_filters->getClusterIds(), range, moreResults);
|
||||
tracks = Service<Scrobbling::IScrobbling>::get()->getTopTracks(LmsApp->getDbSession(), LmsApp->getUser(), _filters->getClusterIds(), range, moreResults);
|
||||
break;
|
||||
|
||||
case Mode::RecentlyAdded:
|
||||
|
||||
@@ -1289,11 +1289,6 @@ testSingleUser(Session& session)
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool hasMore {};
|
||||
CHECK(user->getPlayedTrackList(session)->getCount() == 0);
|
||||
CHECK(user->getPlayedTrackList(session)->getTopTracks({}, Range {0, 1}, hasMore).empty());
|
||||
CHECK(user->getPlayedTrackList(session)->getTopArtists({}, std::nullopt, Range {0, 1}, hasMore).empty());
|
||||
CHECK(user->getPlayedTrackList(session)->getTopReleases({}, Range {0, 1}, hasMore).empty());
|
||||
CHECK(user->getQueuedTrackList(session)->getCount() == 0);
|
||||
}
|
||||
}
|
||||
@@ -1423,6 +1418,34 @@ testSingleTrackListMultipleTrack(Session& session)
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testSingleTrackListMultipleTrackDateTime(Session& session)
|
||||
{
|
||||
ScopedUser user {session, "MyUser"};
|
||||
ScopedTrackList trackList {session, "MytrackList", TrackList::Type::Playlist, false, user.lockAndGet()};
|
||||
ScopedTrack track1 {session, "MyTrack1"};
|
||||
ScopedTrack track2 {session, "MyTrack2"};
|
||||
ScopedTrack track3 {session, "MyTrack3"};
|
||||
|
||||
{
|
||||
Wt::WDateTime now {Wt::WDateTime::currentDateTime()};
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
TrackListEntry::create(session, track1.get(), trackList.get(), now);
|
||||
TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(-1));
|
||||
TrackListEntry::create(session, track3.get(), trackList.get(), now.addSecs(1));
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool moreResults;
|
||||
const auto tracks {trackList.get()->getTracksReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(tracks.size() == 3);
|
||||
CHECK(tracks.front().id() == track3.getId());
|
||||
CHECK(tracks.back().id() == track2.getId());
|
||||
}
|
||||
}
|
||||
|
||||
static
|
||||
void
|
||||
testSingleTrackListMultipleTrackSingleCluster(Session& session)
|
||||
@@ -1512,6 +1535,115 @@ testSingleTrackListMultipleTrackMultiClusters(Session& session)
|
||||
}
|
||||
}
|
||||
|
||||
static
|
||||
void
|
||||
testSingleTrackListMultipleTrackRecentlyPlayed(Session& session)
|
||||
{
|
||||
ScopedUser user {session, "MyUser"};
|
||||
ScopedTrackList trackList {session, "MyTrackList", TrackList::Type::Playlist, false, user.lockAndGet()};
|
||||
ScopedClusterType clusterType {session, "MyClusterType"};
|
||||
ScopedTrack track1 {session, "MyTrack1"};
|
||||
ScopedTrack track2 {session, "MyTrack1"};
|
||||
ScopedArtist artist1 {session, "MyArtist1"};
|
||||
ScopedArtist artist2 {session, "MyArtist2"};
|
||||
ScopedRelease release1 {session, "MyRelease1"};
|
||||
ScopedRelease release2 {session, "MyRelease2"};
|
||||
|
||||
const Wt::WDateTime now {Wt::WDateTime::currentDateTime()};
|
||||
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
track1.get().modify()->setRelease(release1.get());
|
||||
track2.get().modify()->setRelease(release2.get());
|
||||
TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist);
|
||||
TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist);
|
||||
}
|
||||
{
|
||||
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool moreResults {};
|
||||
CHECK(trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults).empty());
|
||||
CHECK(trackList->getReleasesReverse({}, std::nullopt, moreResults).empty());
|
||||
CHECK(trackList->getTracksReverse({}, std::nullopt, moreResults).empty());
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track1.get(), trackList.get(), now);
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool moreResults {};
|
||||
const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)};
|
||||
CHECK(artists.size() == 1);
|
||||
CHECK(artists.front().id() == artist1.getId());
|
||||
|
||||
const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(releases.size() == 1);
|
||||
CHECK(releases.front().id() == release1.getId());
|
||||
|
||||
const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(tracks.size() == 1);
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1));
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool moreResults {};
|
||||
const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)};
|
||||
CHECK(artists.size() == 2);
|
||||
CHECK(artists[0].id() == artist2.getId());
|
||||
CHECK(artists[1].id() == artist1.getId());
|
||||
|
||||
const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(releases.size() == 2);
|
||||
CHECK(releases[0].id() == release2.getId());
|
||||
CHECK(releases[1].id() == release1.getId());
|
||||
|
||||
const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(tracks.size() == 2);
|
||||
CHECK(tracks[0].id() == track2.getId());
|
||||
CHECK(tracks[1].id() == track1.getId());
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2));
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction {session.createSharedTransaction()};
|
||||
|
||||
bool moreResults {};
|
||||
const auto artists {trackList->getArtistsReverse({}, std::nullopt, std::nullopt, moreResults)};
|
||||
CHECK(artists.size() == 2);
|
||||
CHECK(artists[0].id() == artist1.getId());
|
||||
CHECK(artists[1].id() == artist2.getId());
|
||||
|
||||
const auto releases {trackList->getReleasesReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(releases.size() == 2);
|
||||
CHECK(releases[0].id() == release1.getId());
|
||||
CHECK(releases[1].id() == release2.getId());
|
||||
|
||||
const auto tracks {trackList->getTracksReverse({}, std::nullopt, moreResults)};
|
||||
CHECK(tracks.size() == 2);
|
||||
CHECK(tracks[0].id() == track1.getId());
|
||||
CHECK(tracks[1].id() == track2.getId());
|
||||
}
|
||||
}
|
||||
|
||||
static
|
||||
void
|
||||
testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
@@ -1529,6 +1661,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
ScopedRelease release1 {session, "MyRelease1"};
|
||||
ScopedRelease release2 {session, "MyRelease2"};
|
||||
|
||||
const Wt::WDateTime now {Wt::WDateTime::currentDateTime()};
|
||||
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
@@ -1555,7 +1689,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track1.get(), trackList.get());
|
||||
TrackListEntry::create(session, track1.get(), trackList.get(), now);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1641,7 +1775,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track2.get(), trackList.get());
|
||||
TrackListEntry::create(session, track2.get(), trackList.get(), now.addSecs(1));
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1721,7 +1855,7 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
{
|
||||
auto transaction {session.createUniqueTransaction()};
|
||||
|
||||
TrackListEntry::create(session, track1.get(), trackList.get());
|
||||
TrackListEntry::create(session, track1.get(), trackList.get(), now.addSecs(2));
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1750,9 +1884,8 @@ testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed(Session& session)
|
||||
bool moreResults {};
|
||||
const auto artists {trackList->getArtistsReverse({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)};
|
||||
CHECK(artists.size() == 2);
|
||||
// TODO investigate
|
||||
// CHECK(artists[0].id() == artist1.getId());
|
||||
// CHECK(artists[1].id() == artist2.getId());
|
||||
CHECK(artists[0].id() == artist1.getId());
|
||||
CHECK(artists[1].id() == artist2.getId());
|
||||
|
||||
const auto releases {trackList->getReleasesReverse({cluster3.getId()}, std::nullopt, moreResults)};
|
||||
CHECK(releases.size() == 2);
|
||||
@@ -1965,7 +2098,6 @@ testDatabaseEmpty(Session& session)
|
||||
|
||||
int main()
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
// log to stdout
|
||||
@@ -2036,8 +2168,10 @@ int main()
|
||||
|
||||
RUN_TEST(testSingleTrackList);
|
||||
RUN_TEST(testSingleTrackListMultipleTrack);
|
||||
RUN_TEST(testSingleTrackListMultipleTrackDateTime);
|
||||
RUN_TEST(testSingleTrackListMultipleTrackSingleCluster);
|
||||
RUN_TEST(testSingleTrackListMultipleTrackMultiClusters);
|
||||
RUN_TEST(testSingleTrackListMultipleTrackRecentlyPlayed);
|
||||
RUN_TEST(testSingleTrackListMultipleTrackMultiClustersRecentlyPlayed);
|
||||
RUN_TEST(testMultipleTracksMultipleArtistsMultiClusters);
|
||||
RUN_TEST(testMultipleTracksMultipleReleasesMultiClusters);
|
||||
|
||||
@@ -102,11 +102,11 @@ void parse(MetaData::IParser& parser, const std::filesystem::path& file)
|
||||
|
||||
std::cout << "Title: " << track->title << std::endl;
|
||||
|
||||
if (track->musicBrainzTrackID)
|
||||
std::cout << "MB TrackID = " << track->musicBrainzTrackID->getAsString() << std::endl;
|
||||
if (track->trackMBID)
|
||||
std::cout << "track MBID = " << track->trackMBID->getAsString() << std::endl;
|
||||
|
||||
if (track->musicBrainzRecordID)
|
||||
std::cout << "MB RecordID = " << track->musicBrainzRecordID->getAsString() << std::endl;
|
||||
if (track->recordingMBID)
|
||||
std::cout << "recording MBID = " << track->recordingMBID->getAsString() << std::endl;
|
||||
|
||||
for (const auto& cluster : track->clusters)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user