mirror of
https://github.com/hanxi/xiaomusic.git
synced 2025-12-11 15:38:14 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca6e47da5 | ||
|
|
415e75d4b4 | ||
|
|
3c5573a2fc | ||
|
|
7275b59d40 | ||
|
|
a8d0631c33 | ||
|
|
3cfc96b779 | ||
|
|
489f3f1d6f | ||
|
|
a5f2fc195e | ||
|
|
393dbabf4b | ||
|
|
444e697f9d | ||
|
|
cf01039b53 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
|||||||
|
## v0.3.25 (2024-08-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 设置页面支持配置 use_music_api 选项
|
||||||
|
|
||||||
|
## v0.3.24 (2024-08-01)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- #131 修复多设备切换时播放模式显示错误问题
|
||||||
|
|
||||||
|
## v0.3.23 (2024-08-01)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- 修复部分文件获取不到播放时长问题
|
||||||
|
- 处理安全问题
|
||||||
|
|
||||||
## v0.3.22 (2024-08-01)
|
## v0.3.22 (2024-08-01)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ services:
|
|||||||
XIAOMUSIC_PORT: 5678
|
XIAOMUSIC_PORT: 5678
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果不是首次修改端口,还需要修改 setting.json 文件里的端口。
|
||||||
|
|
||||||
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
|
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
|
||||||
|
|
||||||
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。
|
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。
|
||||||
|
|||||||
39
pdm.lock
generated
39
pdm.lock
generated
@@ -25,14 +25,27 @@ files = [
|
|||||||
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
|
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohappyeyeballs"
|
||||||
|
version = "2.3.4"
|
||||||
|
requires_python = "<4.0,>=3.8"
|
||||||
|
summary = "Happy Eyeballs for asyncio"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_full_version == \"3.10.12\""
|
||||||
|
files = [
|
||||||
|
{file = "aiohappyeyeballs-2.3.4-py3-none-any.whl", hash = "sha256:40a16ceffcf1fc9e142fd488123b2e218abc4188cf12ac20c67200e1579baa42"},
|
||||||
|
{file = "aiohappyeyeballs-2.3.4.tar.gz", hash = "sha256:7e1ae8399c320a8adec76f6c919ed5ceae6edd4c3672f4d9eae2b27e37c80ff6"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.9.5"
|
version = "3.10.0"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "Async http client/server framework (asyncio)"
|
summary = "Async http client/server framework (asyncio)"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "python_full_version == \"3.10.12\""
|
marker = "python_full_version == \"3.10.12\""
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aiohappyeyeballs>=2.3.0",
|
||||||
"aiosignal>=1.1.2",
|
"aiosignal>=1.1.2",
|
||||||
"async-timeout<5.0,>=4.0; python_version < \"3.11\"",
|
"async-timeout<5.0,>=4.0; python_version < \"3.11\"",
|
||||||
"attrs>=17.3.0",
|
"attrs>=17.3.0",
|
||||||
@@ -41,8 +54,8 @@ dependencies = [
|
|||||||
"yarl<2.0,>=1.0",
|
"yarl<2.0,>=1.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"},
|
{file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ef0135d7ab7fb0284342fbbf8e8ddf73b7fee8ecc55f5c3a3d0a6b765e6d8b"},
|
||||||
{file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"},
|
{file = "aiohttp-3.10.0.tar.gz", hash = "sha256:e8dd7da2609303e3574c95b0ec9f1fd49647ef29b94701a2862cceae76382e1d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1094,7 +1107,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.30.3"
|
version = "0.30.4"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "The lightning-fast ASGI server."
|
summary = "The lightning-fast ASGI server."
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -1105,13 +1118,13 @@ dependencies = [
|
|||||||
"typing-extensions>=4.0; python_version < \"3.11\"",
|
"typing-extensions>=4.0; python_version < \"3.11\"",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
|
{file = "uvicorn-0.30.4-py3-none-any.whl", hash = "sha256:06b00e3087e58c6865c284143c0c42f810b32ff4f265ab19d08c566f74a08728"},
|
||||||
{file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"},
|
{file = "uvicorn-0.30.4.tar.gz", hash = "sha256:00db9a9e3711a5fa59866e2b02fac69d8dc70ce0814aaec9a66d1d9e5c832a30"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.30.3"
|
version = "0.30.4"
|
||||||
extras = ["standard"]
|
extras = ["standard"]
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "The lightning-fast ASGI server."
|
summary = "The lightning-fast ASGI server."
|
||||||
@@ -1122,14 +1135,14 @@ dependencies = [
|
|||||||
"httptools>=0.5.0",
|
"httptools>=0.5.0",
|
||||||
"python-dotenv>=0.13",
|
"python-dotenv>=0.13",
|
||||||
"pyyaml>=5.1",
|
"pyyaml>=5.1",
|
||||||
"uvicorn==0.30.3",
|
"uvicorn==0.30.4",
|
||||||
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"",
|
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"",
|
||||||
"watchfiles>=0.13",
|
"watchfiles>=0.13",
|
||||||
"websockets>=10.4",
|
"websockets>=10.4",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
|
{file = "uvicorn-0.30.4-py3-none-any.whl", hash = "sha256:06b00e3087e58c6865c284143c0c42f810b32ff4f265ab19d08c566f74a08728"},
|
||||||
{file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"},
|
{file = "uvicorn-0.30.4.tar.gz", hash = "sha256:00db9a9e3711a5fa59866e2b02fac69d8dc70ce0814aaec9a66d1d9e5c832a30"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1345,7 +1358,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2024.7.25"
|
version = "2024.8.1"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "A feature-rich command-line audio/video downloader"
|
summary = "A feature-rich command-line audio/video downloader"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -1361,6 +1374,6 @@ dependencies = [
|
|||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "yt_dlp-2024.7.25-py3-none-any.whl", hash = "sha256:f44b5f33776b4f718900c670fe6e4698fb6fcd426455cd837cf25a1d6d4d9560"},
|
{file = "yt_dlp-2024.8.1-py3-none-any.whl", hash = "sha256:d0d927038e30a05f6eab26ff6189628456ea21bb159a3d9dc2e855eef2810eac"},
|
||||||
{file = "yt_dlp-2024.7.25.tar.gz", hash = "sha256:7587aa25e236cf7b14bdb9378bbffff51202d901b04202be0cf62cbb56d3b52c"},
|
{file = "yt_dlp-2024.8.1.tar.gz", hash = "sha256:4318aa523694611562f01419c8d526b662a72df34ef8ba454016b34c8366c158"},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "xiaomusic"
|
name = "xiaomusic"
|
||||||
version = "0.3.22"
|
version = "0.3.25"
|
||||||
description = "Play Music with xiaomi AI speaker"
|
description = "Play Music with xiaomi AI speaker"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
||||||
|
|||||||
32
test/test_music_duration.py
Normal file
32
test/test_music_duration.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from xiaomusic.const import (
|
||||||
|
SUPPORT_MUSIC_TYPE,
|
||||||
|
)
|
||||||
|
from xiaomusic.utils import (
|
||||||
|
get_local_music_duration,
|
||||||
|
traverse_music_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_music(filename):
|
||||||
|
# 获取播放时长
|
||||||
|
duration = await get_local_music_duration(filename)
|
||||||
|
sec = math.ceil(duration)
|
||||||
|
print(f"本地歌曲 : {filename} 的时长 {duration} {sec} 秒")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(directory):
|
||||||
|
# 获取所有歌曲文件
|
||||||
|
local_musics = traverse_music_directory(directory, 10, [], SUPPORT_MUSIC_TYPE)
|
||||||
|
print(local_musics)
|
||||||
|
for _, files in local_musics.items():
|
||||||
|
for file in files:
|
||||||
|
await test_one_music(file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
directory = "./music" # 替换为你的音乐目录路径
|
||||||
|
asyncio.run(main(directory))
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.22"
|
__version__ = "0.3.25"
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ range_pattern = re.compile(r"bytes=(\d+)-(\d*)")
|
|||||||
@app.get("/music/{file_path:path}")
|
@app.get("/music/{file_path:path}")
|
||||||
async def music_file(request: Request, file_path: str):
|
async def music_file(request: Request, file_path: str):
|
||||||
absolute_path = os.path.abspath(config.music_path)
|
absolute_path = os.path.abspath(config.music_path)
|
||||||
absolute_file_path = os.path.join(absolute_path, file_path)
|
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
|
||||||
|
if not absolute_file_path.startswith(absolute_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if not os.path.exists(absolute_file_path):
|
if not os.path.exists(absolute_file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,9 @@ range_pattern = re.compile(r"bytes=(\d+)-(\d*)")
|
|||||||
@app.get("/music/{file_path:path}")
|
@app.get("/music/{file_path:path}")
|
||||||
async def music_file(request: Request, file_path: str):
|
async def music_file(request: Request, file_path: str):
|
||||||
absolute_path = os.path.abspath(config.music_path)
|
absolute_path = os.path.abspath(config.music_path)
|
||||||
absolute_file_path = os.path.join(absolute_path, file_path)
|
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
|
||||||
|
if not absolute_file_path.startswith(absolute_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if not os.path.exists(absolute_file_path):
|
if not os.path.exists(absolute_file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ $(function(){
|
|||||||
.prop('selected', value === did);
|
.prop('selected', value === did);
|
||||||
$("#did").append(option);
|
$("#did").append(option);
|
||||||
|
|
||||||
if (cur_device.play_type == PLAY_TYPE_ALL) {
|
if (value === did) {
|
||||||
$("#play_type_all").css('background-color', '#b1a8f3');
|
if (cur_device.play_type == PLAY_TYPE_ALL) {
|
||||||
$("#play_type_all").text('✔️ 全部循环');
|
$("#play_type_all").css('background-color', '#b1a8f3');
|
||||||
} else if (cur_device.play_type == PLAY_TYPE_ONE) {
|
$("#play_type_all").text('✔️ 全部循环');
|
||||||
$("#play_type_one").css('background-color', '#b1a8f3');
|
} else if (cur_device.play_type == PLAY_TYPE_ONE) {
|
||||||
$("#play_type_one").text('✔️ 单曲循环');
|
$("#play_type_one").css('background-color', '#b1a8f3');
|
||||||
} else if (cur_device.play_type == PLAY_TYPE_RND) {
|
$("#play_type_one").text('✔️ 单曲循环');
|
||||||
$("#play_type_rnd").css('background-color', '#b1a8f3');
|
} else if (cur_device.play_type == PLAY_TYPE_RND) {
|
||||||
$("#play_type_rnd").text('✔️ 随机播放');
|
$("#play_type_rnd").css('background-color', '#b1a8f3');
|
||||||
|
$("#play_type_rnd").text('✔️ 随机播放');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,6 +74,7 @@ $(function(){
|
|||||||
localStorage.setItem('cur_did', did);
|
localStorage.setItem('cur_did', did);
|
||||||
window.did = did;
|
window.did = did;
|
||||||
console.log('cur_did', did);
|
console.log('cur_did', did);
|
||||||
|
location.reload();
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>Debug For XiaoMusic</title>
|
<title>Debug For XiaoMusic</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722471797">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1723807100">
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722471797"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1723807100"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var vConsole = new window.VConsole();
|
var vConsole = new window.VConsole();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>小爱音箱操控面板</title>
|
<title>小爱音箱操控面板</title>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722471797"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1723807100"></script>
|
||||||
<script src="/static/app.js?version=1722471797"></script>
|
<script src="/static/app.js?version=1723807100"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722471797">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1723807100">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>M3U to JSON Converter</title>
|
<title>M3U to JSON Converter</title>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722471797">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1723807100">
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>小爱音箱操控面板</title>
|
<title>小爱音箱操控面板</title>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722471797"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1723807100"></script>
|
||||||
<script src="/static/setting.js?version=1722471797"></script>
|
<script src="/static/setting.js?version=1723807100"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722471797">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1723807100">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
@@ -133,6 +133,12 @@ var vConsole = new window.VConsole();
|
|||||||
<option value="false" selected>false</option>
|
<option value="false" selected>false</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label for="use_music_api">触屏版兼容模式:</label>
|
||||||
|
<select id="use_music_api">
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false" selected>false</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
|
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
|
||||||
<input id="public_port" type="number" value="0"></input>
|
<input id="public_port" type="number" value="0"></input>
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,7 @@ def _append_files_result(result, root, joinpath, files, support_extension):
|
|||||||
result[dir_name].append(os.path.join(joinpath, file))
|
result[dir_name].append(os.path.join(joinpath, file))
|
||||||
|
|
||||||
|
|
||||||
def traverse_music_directory(
|
def traverse_music_directory(directory, depth, exclude_dirs, support_extension):
|
||||||
directory, depth=10, exclude_dirs=None, support_extension=None
|
|
||||||
):
|
|
||||||
result = {}
|
result = {}
|
||||||
for root, dirs, files in os.walk(directory, followlinks=True):
|
for root, dirs, files in os.walk(directory, followlinks=True):
|
||||||
# 忽略排除的目录
|
# 忽略排除的目录
|
||||||
@@ -247,10 +245,9 @@ async def get_local_music_duration(filename):
|
|||||||
m = await loop.run_in_executor(None, mutagen.mp3.MP3, filename)
|
m = await loop.run_in_executor(None, mutagen.mp3.MP3, filename)
|
||||||
else:
|
else:
|
||||||
m = await loop.run_in_executor(None, mutagen.File, filename)
|
m = await loop.run_in_executor(None, mutagen.File, filename)
|
||||||
if m and m.info:
|
duration = m.info.length
|
||||||
duration = m.info.length
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error getting local music duration: {e}")
|
logging.error(f"Error getting local music {filename} duration: {e}")
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user