Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6cff4247a8
|
|||
|
90ff1382a8
|
|||
|
1450357b91
|
|||
|
7a31c36c10
|
|||
|
5271c12525
|
|||
|
d278e4009d
|
|||
|
e3d80ffc2a
|
|||
|
30a8fbad4e
|
|||
|
e7bc625b6d
|
|||
|
2d71e7b0cb
|
|||
|
2297f87fa3
|
|||
|
271c7e5c13
|
|||
|
9fdea6b169
|
|||
|
38e20044e2
|
|||
|
830cbae17a
|
|||
|
51fee5bfe0
|
|||
|
e40fd2625f
|
|||
|
061ef9bdc9
|
|||
|
d478923ce0
|
|||
|
e4032069a5
|
|||
|
73da4f8dc5
|
|||
|
89ff2bf452
|
|||
|
d0f6d19a7e
|
|||
|
977b3e02e9
|
|||
|
a6d82c1f47
|
|||
|
b808d4be99
|
|||
|
5c3fb66db3
|
|||
|
df081d39ca
|
|||
|
2f2254371b
|
|||
|
857a5e9dd9
|
|||
|
2b4bbdf25e
|
|||
|
08a5650b30
|
|||
|
8a9569ea61
|
|||
|
dc380590e7
|
|||
|
e5fa4c2b65
|
|||
|
97693d6bd0
|
|||
|
edd5eeb4c0
|
|||
|
adf0c24f91
|
|||
|
539fbb6501
|
|||
|
f5dec2a0a7
|
|||
|
da59740b47
|
|||
|
cae07f55cd
|
|||
|
84cf09e61b
|
|||
|
36c1990e5e
|
|||
|
21e51756f0
|
|||
|
caf8b47ca0
|
|||
|
51e5f2d0fb
|
|||
|
b0280767cb
|
|||
|
85f25a38ae
|
|||
|
00399785d4
|
|||
|
f3e69b032f
|
|||
|
824866bdd8
|
|||
|
0b76ea08cd
|
|||
|
ba1e96db26
|
|||
|
cb5c752f8f
|
|||
|
ff85724982
|
|||
|
ad388cf83b
|
|||
|
881334cccb
|
|||
|
edc42248ee
|
|||
|
d7b6b3849c
|
|||
|
4fcd962cc9
|
|||
|
c7382a1561
|
|||
|
4199caa5ef
|
|||
|
1cf8df7524
|
|||
|
9ea21fa7f6
|
|||
|
8859640411
|
|||
|
d07df60b5d
|
|||
|
02e5a39814
|
|||
|
522844a447
|
|||
|
32521e1178
|
|||
|
9f4c606b28
|
|||
|
58bb37fede
|
|||
|
ff9774b806
|
|||
|
7cb1a5d02f
|
|||
|
a9c2c2d7f9
|
|||
|
212ab56722
|
|||
|
c2269ac0fc
|
|||
|
14e9ff5a95
|
|||
|
544b5afc0d
|
|||
|
6e9e7252b2
|
|||
|
61d85bba97
|
|||
|
e4c59fd539
|
|||
|
25205c0c0d
|
|||
|
1655962e85
|
|||
|
2d85244ced
|
|||
|
0897fcc9f8
|
|||
|
2dab5cd109
|
|||
|
a84dfe8178
|
|||
|
a367b9253e
|
|||
|
b295707a05
|
|||
|
82da1aa48b
|
|||
|
359416ea44
|
|||
|
5608693c06
|
|||
|
d6f9a03786
|
|||
|
7188e73783
|
|||
|
027aef4070
|
|||
|
14f3c1c8da
|
|||
|
80802f95f8
|
|||
|
214ad6c285
|
|||
|
64d1e3ff78
|
|||
|
297643ad91
|
|||
|
7efde3cf6f
|
|||
|
b0e57099ba
|
|||
|
435e3605f7
|
|||
|
0edc7f7141
|
|||
|
6fdd0d2a9e
|
|||
|
465517e5cc
|
|||
|
fc735c88d3
|
|||
|
82c198d45b
|
|||
|
73828c547c
|
|||
|
97083114fb
|
|||
|
1c14997b85
|
|||
|
d59e40c6fa
|
|||
|
47b178ac90
|
|||
|
922b2370ee
|
|||
|
83ab1a91b2
|
|||
|
4bfcf460c9
|
|||
|
28127f6138
|
|||
|
0c9048072f
|
|||
|
22f7ea8476
|
|||
|
e1d9eac514
|
|||
|
1b0688e523
|
|||
|
f1e8dcfad4
|
|||
|
ab67575976
|
|||
|
adee9bcb65
|
|||
|
d7ca68aad1
|
|||
|
a826e4bf29
|
|||
|
d4718ac120
|
|||
|
7a10922ec4
|
|||
|
164dd0f282
|
|||
|
f32c922faf
|
|||
|
80462efebc
|
|||
|
12739be2f5
|
|||
|
6b8bfedb9b
|
|||
|
e87b4823d9
|
|||
|
a2cb098330
|
|||
|
93a0fe7a31
|
|||
|
003f8cace2
|
|||
|
2c802ca807
|
|||
|
f71544caab
|
|||
|
dfc0b43bdd
|
|||
|
5a68cea2f3
|
|||
|
047f15426b
|
|||
|
d2c852d57a
|
|||
|
af444f0bbb
|
|||
|
1bbcecfb2e
|
|||
|
b96daa07c6
|
|||
|
1f960f8f64
|
|||
|
e608a6b1df
|
|||
|
f3a95973e9
|
|||
|
c580ca245f
|
|||
|
9b4c0b24ef
|
|||
|
c418634515
|
|||
|
87ac0cecb7
|
|||
|
1d49689171
|
|||
|
fdd41397bf
|
|||
|
7e5c92dd63
|
|||
|
83f2b76cbc
|
|||
|
05a569e395
|
|||
|
e961c10d4e
|
|||
|
2d7ac69db5
|
|||
|
47a60ae671
|
|||
|
ca8b6cb893
|
|||
|
258bf9869f
|
|||
|
be2515231c
|
|||
|
2358335d4e
|
|||
|
1bef4d0272
|
|||
|
546385a484
|
|||
|
d8470d0f4b
|
|||
|
85a6c2b859
|
|||
|
057e21285b
|
|||
|
e485d1a8c5
|
|||
|
e3de41fe07
|
|||
|
8a2c8dd8b2
|
|||
|
abc0096ade
|
|||
|
e170c8b842
|
|||
|
d556bbe0c8
|
18
.drone.yml
Normal file
18
.drone.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: frontend-web
|
||||
image: node:19
|
||||
commands:
|
||||
- cd web
|
||||
- npm install
|
||||
- npm run build
|
||||
|
||||
- name: release
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
api_key: da966507c259aa32ccc2d434e930af4a580de785
|
||||
base_url: https://yongyuancv.cn/git/
|
||||
files: dist/*
|
||||
68
.github/workflows/build.yml
vendored
Normal file
68
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# This is a basic workflow to help you get started with Actions
|
||||
|
||||
name: CI
|
||||
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the "master" branch
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
build-backend-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
- name: Build linux backend
|
||||
run: |
|
||||
make linux
|
||||
- name: Upload linux backend
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: backend-linux
|
||||
path: |
|
||||
msw-open-music
|
||||
config.json
|
||||
build-backend-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
- name: Build windows backend
|
||||
run: |
|
||||
go build -v
|
||||
- name: Upload linux backend
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: backend-windows
|
||||
path: |
|
||||
msw-open-music.exe
|
||||
config.json
|
||||
build-frontend-web:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Build web front end
|
||||
run: |
|
||||
make web
|
||||
- name: upload packaged front end
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: frontend-web
|
||||
path: web/build
|
||||
15
Makefile
15
Makefile
@@ -1,13 +1,10 @@
|
||||
dist:
|
||||
mkdir -p dist
|
||||
minify web/index.js web/*.html web/*.css -o dist/
|
||||
cp -rf web/*.png dist/web/
|
||||
cp -f web/axios.min.js dist/web/axios.min.js
|
||||
cp -f web/vue.global.prod.js dist/web/vue.js
|
||||
cp -f web/vue-router.global.prod.js dist/web/vue-router.js
|
||||
.PHONY: web linux windows
|
||||
web:
|
||||
cd web && npm install
|
||||
cd web && npm run build
|
||||
|
||||
linux:
|
||||
go build
|
||||
go build -v -ldflags '-linkmode=external -extldflags=-static' -tags sqlite_omit_load_extension,netgo
|
||||
|
||||
windows:
|
||||
CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build
|
||||
CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -v
|
||||
|
||||
137
README-cn.md
Normal file
137
README-cn.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# MSW Open Music Project
|
||||
|
||||
[](https://github.com/heimoshuiyu/msw-open-music/actions/workflows/build.yml)
|
||||
|
||||
🔴 演示 Demo: <https://msw-open-music.live>
|
||||
|
||||
> 找一首歌最好的方法是:打开一个超长的歌单,然后随机播放,直到你找到为止。
|
||||
|
||||
一个 💪 轻量级 ⚡️ 高性能 🖥️ 跨平台的 个人音乐串流平台。管理你现有的音乐文件并在其他设备上播放。
|
||||
|
||||
前端网页应用基于 `react.js` 和 `water.css` 构建。后端服务器程序使用 `golang` 和 `sqlite` 构建。
|
||||
|
||||
## 介绍
|
||||
|
||||
截图
|
||||
|
||||

|
||||
|
||||
### 功能特点
|
||||
|
||||
- 🔎 索引现有的音乐文件,并记录文件名和文件夹元信息
|
||||
|
||||
- 📕 使用 文件夹 📁 标签 🏷️ 评论 💬 来管理你的音乐。
|
||||
|
||||
- 🌐 提供一个轻量高效的网页前端并支持多种语言。
|
||||
|
||||
- 👥 支持多用户。
|
||||
|
||||
- 🔥 调用 `ffmpeg` 配合可自定义的预设配置来转码你的音乐。
|
||||
|
||||
- 🔗 分享音乐链接给好友!
|
||||
|
||||
### 如果你遇到过这样的烦恼...你就是目标用户
|
||||
|
||||
- 硬盘上存了一堆音乐,但没有一个很好的播放器. 🖴
|
||||
|
||||
- 下载了体积非常大的无损音乐,在设备间移动很困难. 🎵
|
||||
|
||||
- 想要在其他 电脑/手机 上听 电脑/服务器 上储存的音乐. 😋
|
||||
|
||||
- 想给你的好友分享本地音乐. 😘
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 修改 `config.json` 配置文件中的 `secret` 值
|
||||
|
||||
2. 运行后端服务器程序 `msw-open-music.exe` 或者 `msw-open-music`. 服务默认监听 8080 端口。 然后打开 <http://127.0.0.1:8080> 去创建的一个管理员帐号。
|
||||
|
||||
前端 HTML 文件存放在 `web/build` 目录下。
|
||||
|
||||
### 创建第一个管理员帐号
|
||||
|
||||
第一个创建的管理员帐号会被自动激活,其他后续创建的管理员帐号需要管理员手动激活。
|
||||
|
||||
请前往注册页面,选择角色为 管理员,然后注册第一个管理员帐号。
|
||||
|
||||
#### config.json
|
||||
|
||||
- `secret` 字符串类型。用来加密 session 会话。
|
||||
|
||||
- `database_name` 字符串类型。`sqlite3` 数据库的文件名。如果不存在,会自动创建。
|
||||
- `addr` 字符串类型。监听地址和端口。
|
||||
- `ffmpeg_config_list` 列表类型。预设的 `ffmpeg` 配置文件。包含 `ffmpegConfig` 对象。
|
||||
- `file_life_time` 整数类型(秒)。临时文件的生命周期。如果临时文件超过这个时间没有被访问,那么将会被自动删除。
|
||||
- `cleaner_internal` 整数类型(秒)。`tmpfs` 检查临时文件的间隔时间。
|
||||
- `root` 字符串类型。存放临时文件的目录。默认是 `/tmp`。**Windows用户请修改成可用的目录**。如果不存在,将会被自动创建。
|
||||
- `permission` 各个 API 的权限等级。
|
||||
- `0` 无需任何权限。
|
||||
- `1` 需要管理员(最高级别)权限等级。
|
||||
- `2` 需要普通用户权限等级,也就是说,管理员和普通用户都有权访问此等级的 API ,而 匿名用户 则没有权限访问。
|
||||
- 如果你想避免 API 被滥用,可以调整下面 5 个与串流相关的 API 权限等级。
|
||||
- `/get_file` 使用 `io.copy()` 方法串流
|
||||
- `/get_file_direct` 使用 `http.serveFile()` 方法串流
|
||||
- `/get_file_stream` 调用 `ffmpeg` 并串流其标准输出 `stdout`
|
||||
- `/prepare_file_stream_direct` 调用 `ffmpeg` 预转码一个文件
|
||||
- `/get_file_stream_direct` 使用 `http.serveFile()` 获取预转码结束的临时文件
|
||||
- 其他在 `config.json` 中没有设定的 API 将默认拥有 `0` 的权限等级。
|
||||
|
||||
对于 Windows 用户,请确保 `ffmpeg` 正确安装并设置环境变量。
|
||||
|
||||
## 开发
|
||||
|
||||
欢迎任何 issue / pull request / feature request
|
||||
|
||||
### 主要变更历史
|
||||
|
||||
- `v1.0.0` 第一个版本。核心串流功能可用。
|
||||
- `v1.1.0` 使用 `React` 重构前端。
|
||||
- `v1.2.0` 数据库 DBMS 课程作业。添加 用户、标签、评论 和其他功能。
|
||||
|
||||
### ER Diagram
|
||||
|
||||
Database Entities Relationship Diagram
|
||||
|
||||

|
||||
|
||||
- `avatar` 目前没有在使用。
|
||||
|
||||
- 第一次运行程序时,程序会自动创建一个 ID 为 `1` 的匿名用户。所有未登陆的用户都会自动登陆到这个账户。
|
||||
|
||||
- `tmpfs` 储存在内存中,每次重新启动后端程序将会清空记录的信息。
|
||||
|
||||
### 关于 tmpfs
|
||||
|
||||
如果前端的播放器勾选了 `预转码` 选项,后端程序会先将文件转码到临时目录中,转码完成后再串流文件。这么做可以实现断点续传,解决由于网络波动导致 `ffmpeg` 管道链接断开而终止转码的问题。
|
||||
|
||||
默认的临时文件夹目录是 `/tmp`,这是 Linux 系统中通用的临时目录。默认的生存时间是 600 秒(10 分钟)。如果超过这个时间没有访问该临时文件,那么后端程序将会自动删除它。
|
||||
|
||||
### 后端 API 设计
|
||||
|
||||
一个不需要返回任何有用数据的 API 将会返回下面的 JSON 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
当错误发生时,后端会返回如下格式的 JSON 对象。`error` 是对错误信息的详细描述文本。
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Wrong password"
|
||||
}
|
||||
```
|
||||
|
||||
不需要传递参数的 API 使用 `GET` 方法,否则使用 `POST` 方法。(忽略 RESTFUL 设计)
|
||||
|
||||
后端使用 cookies 来实现用户会话管理。任何不带 cookies 的请求会被认为是由 匿名用户 发送的(也就是 ID 为 `1` 的用户)
|
||||
|
||||
一些重要的源代码文件
|
||||
|
||||
- `pkg/api/api.go` 定义各个 API 的 URL 和对应函数。
|
||||
|
||||
- `pkg/database/sql_stmt.go` 定义 SQL 语句和做一些初始化工作。
|
||||
|
||||
- `pkg/database/struct.go` 定义 JSON 和 数据库对象 的 数据结构。
|
||||
422
README.md
422
README.md
@@ -1,369 +1,139 @@
|
||||
# MSW Open Music Project
|
||||
|
||||
## 简介
|
||||
[](https://github.com/heimoshuiyu/msw-open-music/actions/workflows/build.yml)
|
||||
|
||||
Fork from `msw-file`,目前是一个音乐播放器。
|
||||
🔴 Demo: <https://msw-open-music.live>
|
||||
|
||||
[中文文档](./README-cn.md)
|
||||
|
||||
> The best way to search for a music is to load up a huge playlist and shuffle until you find it.
|
||||
|
||||
A 💪 light weight ⚡️ blazingly fast 🖥️ cross platform personal music streaming platform. Manage your existing music files and enjoy them on any devices.
|
||||
|
||||
Front-end web application build with `react.js` and `water.css`, back-end build with `golang` and `sqlite`.
|
||||
|
||||
## Introduction
|
||||
|
||||
Screenshot
|
||||
|
||||

|
||||
|
||||
[toc]
|
||||
### Features
|
||||
|
||||
## 编译 & 构建
|
||||
- 🔎 Index your existing music files, and record file name and folder information.
|
||||
|
||||
### 编译后端
|
||||
- 📕 Use folder 📁 tag 🏷️ review 💬 to manage your music.
|
||||
|
||||
`go build`
|
||||
- 🌐 Provide a light weight web application with multi-language support.
|
||||
|
||||
如无任何输出,说明构建成功,可执行程序位于 `msw-open-music`
|
||||
- 👥 Multi-user support.
|
||||
|
||||
### 构建前端
|
||||
- 🔥 Call `ffmpeg` with customizable preset to stream your music.
|
||||
|
||||
`make`
|
||||
- 🔗 Share music with others!
|
||||
|
||||
说明:`Makefile` 脚本中的代码会在 `dist` 目录生成用于生产环境的前端 web 文件。这个脚本做的事情是简单地将 `vue` `vue-router` 等 js 文件替换成生产版本。并使用 `minify` 工具处理 `css` 和 `html` 文件。
|
||||
### Try it if you...
|
||||
|
||||
## 使用
|
||||
- Already saved a lot of music files on disk. 🖴
|
||||
|
||||
### 后端使用
|
||||
- Downloaded tons of huge lossless music. 🎵
|
||||
|
||||
初次使用请配置 `config.json`, **最重要的是配置 `token`** 。
|
||||
- Wants to stream your music files from PC/Server to PC/phone. 😋
|
||||
|
||||
默认 ffmpeg 线程 `ffmpeg_threads` 为 1 ,大于 1 以上的值似乎对编码音频没有效果。
|
||||
- Wants to share your stored music. 😘
|
||||
|
||||
#### config.json 说明
|
||||
## Usage
|
||||
|
||||
- `database_name` 字符串类型,指定 sqlite3 单文件数据库的位置,如果不存在则会自动创建。
|
||||
- `addr` api 服务监听端口,该参数会被传入 `http.Serve.Addr`
|
||||
- `token` 字符串,作为管理密码
|
||||
- `ffmpeg_configs`,字典,其键是 ffmpeg 配置的名称,其值是放入 `ffmpeg -i input.mp3 -vn [此处] -f matroska -` 的参数,类型是字符串。 **注意:** 前端会按键名来排序配置列表,并以列表中的第一项作为默认配置。
|
||||
- `file_life_time` 临时文件生存时间,超过该时间没有访问该临时文件,tmpfs 将删除此文件。
|
||||
- `cleaner_internal` 清理器的检查间隔。
|
||||
- `root` 存放该临时文件目录, **Windows 用户请替换成合适的目录。**
|
||||
1. Modify the `secret` in `config.json`
|
||||
|
||||
### 前端使用
|
||||
2. Run back-end server `msw-open-music.exe` or `msw-open-music`. Server will listen on 8080 port by default. Then open <http://127.0.0.1:8080> to setup first admin account.
|
||||
|
||||
前端文件引用均使用相对路径,将前端文件放到同一目录下即可。
|
||||
The front-end HTML files are under `web/build`
|
||||
|
||||
前端在调用后端 api 时使用的是绝对路径,例如 `/api/v1/hello`。如需更改,可以修改后端 `api.go` 中的 `apiMux` 和 `mux` 的相关属性。
|
||||
### Setup first admin account
|
||||
|
||||
## 关于临时文件夹的说明
|
||||
The first administrator account will be active automatically, other administrator accounts need active manually.
|
||||
|
||||
前端播放器中勾选了 `Prepare` ,后端将转码文件到临时文件夹,然后直链提供文件。这有助于修复网路不稳定时 TCP 链接断开,stream 模式下 ffmpeg 中断输出并且不能断点续传的问题。
|
||||
Go to register page, select the role to admin, and register the first admin account.
|
||||
|
||||
临时文件夹管理器位于 `internal/pkg/tmpfs` 中,默认删除时间是 10 分钟。10分钟内如果没有对该临时文件的访问,则会删除此临时文件。
|
||||
#### config.json
|
||||
|
||||
## Change log
|
||||
- `secret` string type. Secret to encrypt the session.
|
||||
|
||||
- `v1.0.0` 首个版本
|
||||
- `database_name` string type. The filename of `sqlite3` database. Will create if that file doesn't exist.
|
||||
- `addr` string type. The listen address and port.
|
||||
- `ffmpeg_config_list` list type, include `ffmpegConfig` object.
|
||||
- `file_life_time` integer type (second). Life time for temporary file. If the temporary file is not accessed for more than this time, back-end server will delete this file.
|
||||
- `cleaner_internal` integer type (second). Interval for `tmpfs` checking temporary file.
|
||||
- `root` string type. Directory to store temporary files. Default is `/tmp`, **please modify this directory if you are using Windows.** Directory will be created if not exists.
|
||||
- `permission`. Specify each API's permission level.
|
||||
- `0` for no permission required.
|
||||
- `1` require admin level (highest level) permission.
|
||||
- `2` require normal user level permission. That is, both admins and registered users can access to this URL, except anonymous users.
|
||||
- If you want to avoid abuse of the playback API, you can adjust the permission level for these 5 playback-related APIs.
|
||||
- `/get_file` get file with `io.copy()` method
|
||||
- `/get_file_direct` get file with `http.serveFile()` method
|
||||
- `/get_file_stream` call ffmpeg and stream its `stdout` output
|
||||
- `/prepare_file_stream_direct` call ffmpeg to convert a file
|
||||
- `/get_file_stream_direct` get the converted file with `http.serveFile()`
|
||||
- Other URLs not metion in `config.json` will have `0` permission level by default.
|
||||
|
||||
## 后端 API 文档
|
||||
For windows user, make sure you have `ffmpeg` installed.
|
||||
|
||||
说明中带有 `stream` 或 `流` 相关字样的,说明该 API 以 `io.Copy` 方式传输文件,不支持断点续传
|
||||
## Development
|
||||
|
||||
无需返回数据的 API 将返回 OK,某些 API 可能会在 `status` 字段中返回详细的执行信息。
|
||||
Any issues or pull requests are welcome.
|
||||
|
||||
### Major changes log
|
||||
|
||||
- `v1.0.0` First version. Implement the core streaming function.
|
||||
- `v1.1.0` Use `React` to rewrite the font-end web pages.
|
||||
- `v1.2.0` Add user, tag, review and other functions for DBMS course project.
|
||||
|
||||
### ER Diagram
|
||||
|
||||
Database Entities Relationship Diagram
|
||||
|
||||

|
||||
|
||||
- `avatar` is not using currently
|
||||
|
||||
- The first time you run the program, the server will create an anonymous user with id `1`. All users who are not logged in will be automatically logged in to this account.
|
||||
|
||||
- `tmpfs` is store in memory, which will be empty everytime server restart.
|
||||
|
||||
### About tmpfs
|
||||
|
||||
If the `Prepare` mode is enabled in the font-wed player, back-end server will convert the whole file into the temporary folder, then serve file. This can avoid `ffmpeg` pipe break problem cause by unstable network connection while streaming audio.
|
||||
|
||||
The default temporary folder is `/tmp`, which is a `tmpfs` file system in Linux operating system. Default life time for temporary files is 600 seconds (10 minutes). If the temporary file is not accessed for more than this time, back-end server will delete this file.
|
||||
|
||||
### Back-end API design
|
||||
|
||||
API does not need to respond any data will return the following JSON object.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "OK"
|
||||
"status": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
### 公开 API
|
||||
Sometime errors happen, server will return the following JSON object, which `error` is the detailed error message.
|
||||
|
||||
- `/api/v1/hello` OK 测试
|
||||
```json
|
||||
{
|
||||
"error": "Wrong password"
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/v1/get_file` 以流方式获取文件
|
||||
API does not need to send any data should use `GET` method, otherwise use `POST` method.
|
||||
|
||||
- 请求示例
|
||||
Server use cookies to authenticate a user. Any request without cookies will be consider from an anonymous user (aka. user with ID `1`).
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123
|
||||
}
|
||||
```
|
||||
Some important source code files:
|
||||
|
||||
- `/api/v1/get_file_direct` http 标准方式获取文件,支持断点续传,由 `http.ServeFile` 实现
|
||||
- `pkg/api/api.go` define URL
|
||||
|
||||
- 请求示例
|
||||
- `pkg/database/sql_stmt.go` define SQL queries and do the init job.
|
||||
|
||||
`/api/v1/get_file_direct?id=30`
|
||||
|
||||
- `/api/v1/search_files` 搜索文件
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "miku",
|
||||
"limit": 10,
|
||||
"offset" 0
|
||||
}
|
||||
```
|
||||
|
||||
搜索所有文件名中包含 "miku" 的文件
|
||||
|
||||
`limit` 限制返回结果的数量,该值必须在 0~10 之间
|
||||
|
||||
`offset` 是返回结构的偏移量,用于实现翻页功能。
|
||||
|
||||
- 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"id": 30,
|
||||
"folder_id": 100,
|
||||
"folder_name": "wonderful",
|
||||
"filename": "memories.flac",
|
||||
"filesize": 1048576
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"folder_id": 100,
|
||||
"folder_name": "wonderful",
|
||||
"filename": "memories.flac",
|
||||
"filesize": 1048576
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`id` 为文件的唯一标识
|
||||
|
||||
`folder_id` 为该文件所在的文件夹标识
|
||||
|
||||
`foldername` 为该文件所在的文件夹名
|
||||
|
||||
`filename` 为该文件名
|
||||
|
||||
`filesize` 为该文件的大小,单位字节
|
||||
|
||||
- `/api/v1/search_folders` 搜索文件夹
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"foldername": "miku",
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
}
|
||||
```
|
||||
|
||||
搜索所有文件夹名中包含 "miku" 的文件夹。
|
||||
|
||||
`limit` 限制返回结果的数量,该值必须在 0~10 之间
|
||||
|
||||
`offset` 是返回结构的偏移量,用于实现翻页功能。
|
||||
|
||||
- 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"id": 100,
|
||||
"foldername": "folder name"
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"foldername": "folder name"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`id` 为该文件夹的唯一标识
|
||||
|
||||
`foldername` 为该文件夹的名字
|
||||
|
||||
- `/api/v1/get_files_in_folder` 获取指定文件夹中的所有文件
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"folder_id": 123,
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
- 返回示例
|
||||
|
||||
同 `/api/v1/search_files`
|
||||
|
||||
- `/api/v1/get_random_files`
|
||||
|
||||
此 API 随机返回 files 表中 10 个文件。请注意,该操作会造成全表查询,在 AMD 2200G CPU 40000条数据记录情况下最大处理量为 100 请求每秒。
|
||||
|
||||
- 请求示例
|
||||
|
||||
直接 GET `/api/v1/get_random_files`
|
||||
|
||||
- 返回示例
|
||||
|
||||
同 `/api/v1/search_files`
|
||||
|
||||
- `/api/v1/get_file_stream`
|
||||
|
||||
以流方式返回文件
|
||||
|
||||
- 请求示例
|
||||
|
||||
GET `/api/v1/get_file_stream?id=123`
|
||||
|
||||
- `/api/v1/get_ffmpeg_config_list`
|
||||
|
||||
获取 ffmpeg 配置列表
|
||||
|
||||
- 请求示例
|
||||
|
||||
GET `/api/v1/get_ffmpeg_config_list`
|
||||
|
||||
- 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"ffmpeg_configs": {
|
||||
"OPUS 256k": {"args": "-c:a libopus -ab 256k"},
|
||||
"WAV": {"args": "-c:a wav"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/v1/feedback` 反馈
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback": "some suggestions..."
|
||||
}
|
||||
```
|
||||
|
||||
- 返回 OK
|
||||
|
||||
- `/api/v1/get_file_info` 获取单个文件的信息
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"ID": 123
|
||||
}
|
||||
```
|
||||
|
||||
- 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 30,
|
||||
"folder_id": 100,
|
||||
"folder_name": "wonderful",
|
||||
"filename": "memories.flac",
|
||||
"filesize": 1048576
|
||||
},
|
||||
```
|
||||
|
||||
- `/api/v1/get_file_stream_direct` 获取已提前转码好的文件,该 API 支持断点续传
|
||||
|
||||
- 请求示例
|
||||
|
||||
GET `/api/v1/get_file_stream_direct?id=123&config=OPUS 128k`
|
||||
|
||||
- `/api/v1/prepare_file_stream_direct` 请求提前转码文件,该 API 将返回转码后的文件大小
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"config_name": "OPUS 128k"
|
||||
}
|
||||
```
|
||||
|
||||
- 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"filesize": 1973241
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 需要 token 的 API
|
||||
|
||||
- `/api/v1/walk` 遍历目录,并将文件和文件夹添加到数据库中
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "your token",
|
||||
"root": "/path/to/root",
|
||||
"pattern": [".wav", ".flac"]
|
||||
}
|
||||
```
|
||||
|
||||
`token` 此 API 需要 token
|
||||
|
||||
`root` 遍历目录
|
||||
|
||||
`pattern` 文件扩展名列表(包含 `.` ),匹配扩展名的文件才会被添加到数据库
|
||||
|
||||
- 返回 OK
|
||||
|
||||
- `/api/v1/reset` 重置数据库(feedbacks 不会清空)
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "your token"
|
||||
}
|
||||
```
|
||||
|
||||
- 返回 OK
|
||||
|
||||
- `/api/v1/add_ffmpeg_config` 添加 ffmpeg 配置
|
||||
|
||||
注意:目前前端中没有实现此功能
|
||||
|
||||
- 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "your token",
|
||||
"name": "OPUS",
|
||||
"ffmpeg_config": {
|
||||
"args": "-c:a libopus -ab 256k"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`name` 该配置的名字
|
||||
|
||||
`ffmpeg_config` 一个 ffmpeg 的配置
|
||||
|
||||
`args` 该 ffmpeg 配置的参数
|
||||
|
||||
- 返回 OK
|
||||
|
||||
- `/web/*` 返回程序同目录下 web 文件夹中的内容
|
||||
|
||||
此 api 仅用于方便开发,项目根目录中 web 文件夹中的内容并不是生产用(for production)的 js 文件,这个 API 不应该用来提供前端的 web 服务,web 服务应该由其他程序负责(例如 apache caddy nginx 等)
|
||||
|
||||
## 前端 API 文档
|
||||
|
||||
前端只有少量 API ,允许用户直接打开链接就执行某些功能
|
||||
|
||||
- `/web/#/share?id=39`
|
||||
|
||||
分享文件,id 是文件的唯一标识。
|
||||
|
||||
- `/web/#/search_folders?folder_id=2614`
|
||||
|
||||
显示该文件夹中的文件, folder_id 是文件夹的唯一标识。
|
||||
- `pkg/database/struct.go` define JSON structures for database entities.
|
||||
|
||||
99
config.json
99
config.json
@@ -1,23 +1,80 @@
|
||||
{
|
||||
"api": {
|
||||
"database_name": "music.sqlite3",
|
||||
"addr": ":8080",
|
||||
"token": "!! config your very strong token here !!",
|
||||
"ffmpeg_threads": 1,
|
||||
"ffmpeg_configs": {
|
||||
"0. OPUS 128k": {"args": "-c:a libopus -ab 128k"},
|
||||
"1. OPUS 96k": {"args": "-c:a libopus -ab 96k"},
|
||||
"2. OPUS 256k": {"args": "-c:a libopus -ab 256k"},
|
||||
"3. OPUS 320k": {"args": "-c:a libopus -ab 320k"},
|
||||
"4. OPUS 512k": {"args": "-c:a libopus -ab 512k"},
|
||||
"5. AAC 128k": {"args": "-c:a aac -ab 128k"},
|
||||
"6. AAC 256k": {"args": "-c:a aac -ab 256k"},
|
||||
"7. 全损音质 32k": {"args": "-c:a libopus -ab 32k"}
|
||||
}
|
||||
},
|
||||
"tmpfs": {
|
||||
"file_life_time": 600,
|
||||
"cleaner_internal": 1,
|
||||
"root": "/tmp/"
|
||||
}
|
||||
"api": {
|
||||
"secret": "CHANGE_YOUR_SECRET_HERE",
|
||||
"database_name": "postgres://postgres:woshimima@localhost/postgres?sslmode=disable",
|
||||
"single_thread": true,
|
||||
"addr": ":8080",
|
||||
"ffmpeg_threads": 1,
|
||||
"ffmpeg_config_list": [
|
||||
{
|
||||
"name": "WEBM OPUS 128k",
|
||||
"args": "-c:a libopus -ab 128k -vn",
|
||||
"format": "webm"
|
||||
},
|
||||
{
|
||||
"name": "WEBM OPUS 96k",
|
||||
"args": "-c:a libopus -ab 96k -vn",
|
||||
"format": "webm"
|
||||
},
|
||||
{
|
||||
"name": "WEBM OPUS 256k",
|
||||
"args": "-c:a libopus -ab 256k -vn",
|
||||
"format": "webm"
|
||||
},
|
||||
{
|
||||
"name": "WEBM OPUS 512k",
|
||||
"args": "-c:a libopus -ab 512k -vn",
|
||||
"format": "webm"
|
||||
},
|
||||
{
|
||||
"name": "AAC 128k",
|
||||
"args": "-c:a aac -ab 128k -vn",
|
||||
"format": "adts"
|
||||
},
|
||||
{
|
||||
"name": "AAC 256k",
|
||||
"args": "-c:a aac -ab 256k -vn",
|
||||
"format": "adts"
|
||||
},
|
||||
{ "name": "MP3 128k", "args": "-c:a mp3 -ab 128k -vn", "format": "mp3" },
|
||||
{ "name": "MP3 320k", "args": "-c:a mp3 -ab 320k -vn", "format": "mp3" },
|
||||
{
|
||||
"name": "全损音质 8k",
|
||||
"args": "-c:a libopus -ab 8k -vn",
|
||||
"format": "webm"
|
||||
}
|
||||
],
|
||||
"permission": {
|
||||
"/register": 0,
|
||||
"/get_file": 0,
|
||||
"/get_file_direct": 0,
|
||||
"/get_file_stream": 0,
|
||||
"/prepare_file_stream_direct": 0,
|
||||
"/get_file_stream_direct": 0,
|
||||
"/walk": 1,
|
||||
"/reset": 1,
|
||||
"/update_user_active": 1,
|
||||
"/get_feedbacks": 1,
|
||||
"/delete_feedback": 1,
|
||||
"/delete_file": 1,
|
||||
"/update_filename": 1,
|
||||
"/reset_filename": 1,
|
||||
"/reset_foldername": 1,
|
||||
"/update_foldername": 1,
|
||||
"/insert_tag": 1,
|
||||
"/update_tag": 1,
|
||||
"/delete_tag": 1,
|
||||
"/put_tag_on_file": 1,
|
||||
"/delete_tag_on_file": 1,
|
||||
"/delete_review": 2,
|
||||
"/update_review": 2,
|
||||
"/update_user_password": 2,
|
||||
"/update_username": 2
|
||||
}
|
||||
},
|
||||
"tmpfs": {
|
||||
"file_life_time": 600,
|
||||
"cleaner_internal": 1,
|
||||
"root": "/tmp/"
|
||||
}
|
||||
}
|
||||
|
||||
1
docs/ER Diagram.drawio
Normal file
1
docs/ER Diagram.drawio
Normal file
File diff suppressed because one or more lines are too long
BIN
erdiagram.png
Normal file
BIN
erdiagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
10
go.mod
10
go.mod
@@ -1,5 +1,11 @@
|
||||
module msw-open-music
|
||||
|
||||
go 1.16
|
||||
go 1.18
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.7 // indirect
|
||||
require (
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/lib/pq v1.10.7
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
|
||||
)
|
||||
|
||||
require github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,2 +1,8 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
|
||||
@@ -1,729 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"msw-open-music/internal/pkg/database"
|
||||
"msw-open-music/internal/pkg/tmpfs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Db *database.Database
|
||||
Server http.Server
|
||||
token string
|
||||
APIConfig APIConfig
|
||||
Tmpfs *tmpfs.Tmpfs
|
||||
}
|
||||
|
||||
type FfmpegConfigs struct {
|
||||
FfmpegConfigs map[string]*FfmpegConfig `json:"ffmpeg_configs"`
|
||||
}
|
||||
|
||||
type AddFfmpegConfigRequest struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
FfmpegConfig FfmpegConfig `json:"ffmpeg_config"`
|
||||
}
|
||||
|
||||
type FfmpegConfig struct {
|
||||
Args string `json:"args"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
var ok Status = Status{
|
||||
Status: "OK",
|
||||
}
|
||||
|
||||
type WalkRequest struct {
|
||||
Token string `json:"token"`
|
||||
Root string `json:"root"`
|
||||
Pattern []string `json:"pattern"`
|
||||
}
|
||||
|
||||
type ResetRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type SearchFilesRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type SearchFoldersRequest struct {
|
||||
Foldername string `json:"foldername"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type SearchFilesResponse struct {
|
||||
Files []database.File `json:"files"`
|
||||
}
|
||||
|
||||
type SearchFoldersResponse struct {
|
||||
Folders []database.Folder `json:"folders"`
|
||||
}
|
||||
|
||||
type GetFilesInFolderRequest struct {
|
||||
Folder_id int64 `json:"folder_id"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetFilesInFolderResponse struct {
|
||||
Files *[]database.File `json:"files"`
|
||||
}
|
||||
|
||||
type GetRandomFilesResponse struct {
|
||||
Files *[]database.File `json:"files"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetRandomFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := api.Db.GetRandomFiles(10);
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
getRandomFilesResponse := &GetRandomFilesResponse{
|
||||
Files: &files,
|
||||
}
|
||||
log.Println("[api] Get random files")
|
||||
json.NewEncoder(w).Encode(getRandomFilesResponse)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFilesInFolder(w http.ResponseWriter, r *http.Request) {
|
||||
getFilesInFolderRequest := &GetFilesInFolderRequest{
|
||||
Folder_id: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFilesInFolderRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empyt
|
||||
if getFilesInFolderRequest.Folder_id < 0 {
|
||||
api.HandleErrorString(w, r, `"folder_id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := api.Db.GetFilesInFolder(getFilesInFolderRequest.Folder_id, getFilesInFolderRequest.Limit, getFilesInFolderRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
getFilesInFolderResponse := &GetFilesInFolderResponse{
|
||||
Files: &files,
|
||||
}
|
||||
|
||||
log.Println("[api] Get files in folder", getFilesInFolderRequest.Folder_id)
|
||||
|
||||
json.NewEncoder(w).Encode(getFilesInFolderResponse)
|
||||
}
|
||||
|
||||
func (api *API) CheckToken(w http.ResponseWriter, r *http.Request, token string) (error) {
|
||||
if token != api.token {
|
||||
err := errors.New("token not matched")
|
||||
log.Println("[api] [Warning] Token not matched", token)
|
||||
api.HandleErrorCode(w, r, err, 403)
|
||||
return err
|
||||
}
|
||||
log.Println("[api] Token passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *API) HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
api.HandleErrorString(w, r, err.Error())
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorCode(w http.ResponseWriter, r *http.Request, err error, code int) {
|
||||
api.HandleErrorStringCode(w, r, err.Error(), code)
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorString(w http.ResponseWriter, r *http.Request, errorString string) {
|
||||
api.HandleErrorStringCode(w, r, errorString, 500)
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorStringCode(w http.ResponseWriter, r *http.Request, errorString string, code int) {
|
||||
log.Println("[api] [Error]", code, errorString)
|
||||
errStatus := &Status{
|
||||
Status: errorString,
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(errStatus)
|
||||
}
|
||||
|
||||
func (api *API) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||
resetRequest := &ResetRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(resetRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check token
|
||||
err = api.CheckToken(w, r, resetRequest.Token)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// reset
|
||||
err = api.Db.ResetFiles()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
err = api.Db.ResetFolder()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleStatus(w, r, "Database reseted")
|
||||
}
|
||||
|
||||
func (api *API) HandleWalk(w http.ResponseWriter, r *http.Request) {
|
||||
walkRequest := &WalkRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(walkRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check token match
|
||||
err = api.CheckToken(w, r, walkRequest.Token)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// check root empty
|
||||
if walkRequest.Root == "" {
|
||||
api.HandleErrorString(w, r, `key "root" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
// check pattern empty
|
||||
if len(walkRequest.Pattern) == 0 {
|
||||
api.HandleErrorString(w, r, `"[]pattern" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
// walk
|
||||
err = api.Db.Walk(walkRequest.Root, walkRequest.Pattern)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleStatus(w, r, "Database udpated")
|
||||
}
|
||||
|
||||
func (api *API) HandleOK(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(&ok)
|
||||
}
|
||||
|
||||
func (api *API) HandleStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
s := &Status{
|
||||
Status: status,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(s)
|
||||
}
|
||||
|
||||
func (api *API) HandleSearchFiles(w http.ResponseWriter, r *http.Request) {
|
||||
searchFilesRequest := &SearchFilesRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(searchFilesRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if searchFilesRequest.Filename == "" {
|
||||
api.HandleErrorString(w, r, `"filename" can't be empty`)
|
||||
return
|
||||
}
|
||||
if api.CheckLimit(w, r, searchFilesRequest.Limit) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
searchFilesResponse := &SearchFilesResponse{}
|
||||
|
||||
searchFilesResponse.Files, err = api.Db.SearchFiles(searchFilesRequest.Filename, searchFilesRequest.Limit, searchFilesRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Search files", searchFilesRequest.Filename, searchFilesRequest.Limit, searchFilesRequest.Offset)
|
||||
|
||||
json.NewEncoder(w).Encode(searchFilesResponse)
|
||||
}
|
||||
|
||||
func (api *API) CheckLimit(w http.ResponseWriter, r *http.Request, limit int64) (error) {
|
||||
if limit <= 0 || limit > 10 {
|
||||
log.Println("[api] [Warning] Limit error", limit)
|
||||
err := errors.New(`"limit" can't be zero or more than 10`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *API) HandleSearchFolders(w http.ResponseWriter, r *http.Request) {
|
||||
searchFoldersRequest := &SearchFoldersRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(searchFoldersRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if searchFoldersRequest.Foldername == "" {
|
||||
api.HandleErrorString(w, r, `"foldername" can't be empty`)
|
||||
return
|
||||
}
|
||||
if api.CheckLimit(w, r, searchFoldersRequest.Limit) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
searchFoldersResponse := &SearchFoldersResponse{}
|
||||
|
||||
searchFoldersResponse.Folders, err = api.Db.SearchFolders(searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Search folders", searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
|
||||
|
||||
json.NewEncoder(w).Encode(searchFoldersResponse)
|
||||
}
|
||||
|
||||
type GetFileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
getFileRequest := &GetFileRequest{
|
||||
ID: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFileRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if getFileRequest.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := api.Db.GetFile(getFileRequest.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(file)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) CheckGetFileStream(w http.ResponseWriter, r *http.Request) (error) {
|
||||
var err error
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
if len(ids) == 0 {
|
||||
err = errors.New(`parameter "id" can't be empty`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
_, err = strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
err = errors.New(`parameter "id" should be an integer`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
configs := q["config"]
|
||||
if len(configs) == 0 {
|
||||
err = errors.New(`parameter "config" can't be empty`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileStream(w http.ResponseWriter, r *http.Request) {
|
||||
err := api.CheckGetFileStream(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
configs := q["config"]
|
||||
configName := configs[0]
|
||||
file, err := api.Db.GetFile(int64(id))
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Stream file", path, configName)
|
||||
|
||||
ffmpegConfig, ok := api.APIConfig.FfmpegConfigs[configName]
|
||||
if !ok {
|
||||
api.HandleErrorStringCode(w, r, `ffmpeg config not found`, 404)
|
||||
return
|
||||
}
|
||||
args := strings.Split(ffmpegConfig.Args, " ")
|
||||
startArgs := []string {"-threads", strconv.FormatInt(api.APIConfig.FfmpegThreads, 10), "-i", path}
|
||||
endArgs := []string {"-vn", "-f", "ogg", "-"}
|
||||
ffmpegArgs := append(startArgs, args...)
|
||||
ffmpegArgs = append(ffmpegArgs, endArgs...)
|
||||
cmd := exec.Command("ffmpeg", ffmpegArgs...)
|
||||
cmd.Stdout = w
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type PrepareFileStreamDirectRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ConfigName string `json:"config_name"`
|
||||
}
|
||||
|
||||
type PrepareFileStreamDirectResponse struct {
|
||||
Filesize int64 `json:"filesize"`
|
||||
}
|
||||
|
||||
func (api *API) HandlePrepareFileStreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
prepareFileStreamDirectRequst := &PrepareFileStreamDirectRequest{
|
||||
ID: -1,
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(prepareFileStreamDirectRequst)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if prepareFileStreamDirectRequst.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
if prepareFileStreamDirectRequst.ConfigName == "" {
|
||||
api.HandleErrorString(w, r, `"config_name" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := api.Db.GetFile(prepareFileStreamDirectRequst.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
srcPath, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Prepare stream direct file", srcPath, prepareFileStreamDirectRequst.ConfigName)
|
||||
ffmpegConfig, ok := api.APIConfig.FfmpegConfigs[prepareFileStreamDirectRequst.ConfigName]
|
||||
if !ok {
|
||||
api.HandleErrorStringCode(w, r, `ffmpeg config not found`, 404)
|
||||
return
|
||||
}
|
||||
objPath := api.Tmpfs.GetObjFilePath(prepareFileStreamDirectRequst.ID, prepareFileStreamDirectRequst.ConfigName)
|
||||
|
||||
// check obj file exists
|
||||
exists := api.Tmpfs.Exits(objPath)
|
||||
if exists {
|
||||
fileInfo, err := os.Stat(objPath)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
prepareFileStreamDirectResponse := &PrepareFileStreamDirectResponse{
|
||||
Filesize: fileInfo.Size(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(prepareFileStreamDirectResponse)
|
||||
return
|
||||
}
|
||||
|
||||
api.Tmpfs.Record(objPath)
|
||||
args := strings.Split(ffmpegConfig.Args, " ")
|
||||
startArgs := []string {"-threads", strconv.FormatInt(api.APIConfig.FfmpegThreads, 10), "-i", srcPath}
|
||||
endArgs := []string {"-vn", "-y", objPath}
|
||||
ffmpegArgs := append(startArgs, args...)
|
||||
ffmpegArgs = append(ffmpegArgs, endArgs...)
|
||||
cmd := exec.Command("ffmpeg", ffmpegArgs...)
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(objPath)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
prepareFileStreamDirectResponse := &PrepareFileStreamDirectResponse{
|
||||
Filesize: fileInfo.Size(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(prepareFileStreamDirectResponse)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileStreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
err := api.CheckGetFileStream(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
configs := q["config"]
|
||||
configName := configs[0]
|
||||
|
||||
path := api.Tmpfs.GetObjFilePath(int64(id), configName)
|
||||
if api.Tmpfs.Exits(path) {
|
||||
api.Tmpfs.Record(path)
|
||||
}
|
||||
|
||||
log.Println("[api] Get direct cached file", path)
|
||||
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileDirect(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
if len(ids) == 0 {
|
||||
api.HandleErrorString(w, r, `parameter "id" can't be empty`)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
api.HandleErrorString(w, r, `parameter "id" should be an integer`)
|
||||
return
|
||||
}
|
||||
file, err := api.Db.GetFile(int64(id))
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get direct raw file", path)
|
||||
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFile(w http.ResponseWriter, r *http.Request) {
|
||||
getFileRequest := &GetFileRequest{
|
||||
ID: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFileRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if getFileRequest.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := api.Db.GetFile(getFileRequest.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get pipe raw file", path)
|
||||
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
io.Copy(w, src)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFfmpegConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[api] Get ffmpeg config list")
|
||||
ffmpegConfigs:= &FfmpegConfigs{
|
||||
FfmpegConfigs: api.APIConfig.FfmpegConfigs,
|
||||
}
|
||||
json.NewEncoder(w).Encode(&ffmpegConfigs)
|
||||
}
|
||||
|
||||
func (api *API) HandleAddFfmpegConfig(w http.ResponseWriter, r *http.Request) {
|
||||
addFfmpegConfigRequest := AddFfmpegConfigRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&addFfmpegConfigRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check token
|
||||
err = api.CheckToken(w, r, addFfmpegConfigRequest.Token)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// check name and args not null
|
||||
if addFfmpegConfigRequest.Name == "" {
|
||||
api.HandleErrorString(w, r, `"ffmpeg_config.name" can't be empty`)
|
||||
return
|
||||
}
|
||||
if addFfmpegConfigRequest.FfmpegConfig.Args == "" {
|
||||
api.HandleErrorString(w, r, `"ffmpeg_config.args" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Add ffmpeg config")
|
||||
|
||||
api.APIConfig.FfmpegConfigs[addFfmpegConfigRequest.Name] = &addFfmpegConfigRequest.FfmpegConfig
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type FeedbackRequest struct {
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
|
||||
func (api *API) HandleFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
feedbackRequest := &FeedbackRequest{}
|
||||
err :=json.NewDecoder(r.Body).Decode(feedbackRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty feedback
|
||||
if feedbackRequest.Feedback == "" {
|
||||
api.HandleErrorString(w, r, `"feedback" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Feedback", feedbackRequest.Feedback)
|
||||
|
||||
headerBuff := &bytes.Buffer{}
|
||||
err = r.Header.Write(headerBuff)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
header := headerBuff.String()
|
||||
|
||||
err = api.Db.InsertFeedback(time.Now().Unix(), feedbackRequest.Feedback, header)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
func NewAPIConfig() (APIConfig) {
|
||||
apiConfig := APIConfig{
|
||||
FfmpegConfigs: make(map[string]*FfmpegConfig),
|
||||
}
|
||||
return apiConfig
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
DatabaseName string `json:"database_name"`
|
||||
Addr string `json:"addr"`
|
||||
Token string `json:"token"`
|
||||
FfmpegThreads int64 `json:"ffmpeg_threads"`
|
||||
FfmpegConfigs map[string]*FfmpegConfig `json:"ffmpeg_configs"`
|
||||
}
|
||||
|
||||
func NewAPI(apiConfig APIConfig, tmpfsConfig tmpfs.TmpfsConfig) (*API, error) {
|
||||
var err error
|
||||
|
||||
db, err := database.NewDatabase(apiConfig.DatabaseName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
apiMux := http.NewServeMux()
|
||||
|
||||
api := &API{
|
||||
Db: db,
|
||||
Server: http.Server{
|
||||
Addr: apiConfig.Addr,
|
||||
Handler: mux,
|
||||
},
|
||||
APIConfig: apiConfig,
|
||||
}
|
||||
api.Tmpfs = tmpfs.NewTmpfs(tmpfsConfig)
|
||||
|
||||
// mount api
|
||||
apiMux.HandleFunc("/hello", api.HandleOK)
|
||||
apiMux.HandleFunc("/get_file", api.HandleGetFile)
|
||||
apiMux.HandleFunc("/get_file_direct", api.HandleGetFileDirect)
|
||||
apiMux.HandleFunc("/search_files", api.HandleSearchFiles)
|
||||
apiMux.HandleFunc("/search_folders", api.HandleSearchFolders)
|
||||
apiMux.HandleFunc("/get_files_in_folder", api.HandleGetFilesInFolder)
|
||||
apiMux.HandleFunc("/get_random_files", api.HandleGetRandomFiles)
|
||||
apiMux.HandleFunc("/get_file_stream", api.HandleGetFileStream)
|
||||
apiMux.HandleFunc("/get_ffmpeg_config_list", api.HandleGetFfmpegConfigs)
|
||||
apiMux.HandleFunc("/feedback", api.HandleFeedback)
|
||||
apiMux.HandleFunc("/get_file_info", api.HandleGetFileInfo)
|
||||
apiMux.HandleFunc("/get_file_stream_direct", api.HandleGetFileStreamDirect)
|
||||
apiMux.HandleFunc("/prepare_file_stream_direct", api.HandlePrepareFileStreamDirect)
|
||||
// below needs token
|
||||
apiMux.HandleFunc("/walk", api.HandleWalk)
|
||||
apiMux.HandleFunc("/reset", api.HandleReset)
|
||||
apiMux.HandleFunc("/add_ffmpeg_config", api.HandleAddFfmpegConfig)
|
||||
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
||||
mux.Handle("/web/", http.StripPrefix("/web", http.FileServer(http.Dir("web"))))
|
||||
|
||||
api.token = apiConfig.Token
|
||||
|
||||
return api, nil
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var initFilesTableQuery = `CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
folder_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
filesize INTEGER NOT NULL
|
||||
);`
|
||||
var initFoldersTableQuery = `CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
folder TEXT NOT NULL,
|
||||
foldername TEXT NOT NULL
|
||||
);`
|
||||
var initFeedbacksTableQuery = `CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
feedback TEXT NOT NULL,
|
||||
header TEXT NOT NULL
|
||||
);`
|
||||
var insertFolderQuery = `INSERT INTO folders (folder, foldername) VALUES (?, ?);`
|
||||
var findFolderQuery = `SELECT id FROM folders WHERE folder = ? LIMIT 1;`
|
||||
var insertFileQuery = `INSERT INTO files (folder_id, filename, filesize) VALUES (?, ?, ?);`
|
||||
var searchFilesQuery = `SELECT files.id, files.folder_id, files.filename, folders.foldername, files.filesize FROM files JOIN folders ON files.folder_id = folders.id WHERE filename LIKE ? LIMIT ? OFFSET ?;`
|
||||
var getFolderQuery = `SELECT folder FROM folders WHERE id = ? LIMIT 1;`
|
||||
var dropFilesQuery = `DROP TABLE files;`
|
||||
var dropFolderQuery = `DROP TABLE folders;`
|
||||
var getFileQuery = `SELECT files.id, files.folder_id, files.filename, folders.foldername, files.filesize FROM files JOIN folders ON files.folder_id = folders.id WHERE files.id = ? LIMIT 1;`
|
||||
var searchFoldersQuery = `SELECT id, folder, foldername FROM folders WHERE foldername LIKE ? LIMIT ? OFFSET ?;`
|
||||
var getFilesInFolderQuery = `SELECT files.id, files.filename, files.filesize, folders.foldername FROM files JOIN folders ON files.folder_id = folders.id WHERE folder_id = ? LIMIT ? OFFSET ?;`
|
||||
var getRandomFilesQuery = `SELECT files.id, files.folder_id, files.filename, folders.foldername, files.filesize FROM files JOIN folders on files.folder_id = folders.id ORDER BY RANDOM() LIMIT ?;`
|
||||
var insertFeedbackQuery = `INSERT INTO feedbacks (time, feedback, header) VALUES (?, ?, ?);`
|
||||
|
||||
type Database struct {
|
||||
sqlConn *sql.DB
|
||||
stmt *Stmt
|
||||
}
|
||||
|
||||
type Stmt struct {
|
||||
initFilesTable *sql.Stmt
|
||||
initFoldersTable *sql.Stmt
|
||||
initFeedbacksTable *sql.Stmt
|
||||
insertFolder *sql.Stmt
|
||||
insertFile *sql.Stmt
|
||||
findFolder *sql.Stmt
|
||||
searchFiles *sql.Stmt
|
||||
getFolder *sql.Stmt
|
||||
dropFiles *sql.Stmt
|
||||
dropFolder *sql.Stmt
|
||||
getFile *sql.Stmt
|
||||
searchFolders *sql.Stmt
|
||||
getFilesInFolder *sql.Stmt
|
||||
getRandomFiles *sql.Stmt
|
||||
insertFeedback *sql.Stmt
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Db *Database `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Folder_id int64 `json:"folder_id"`
|
||||
Foldername string `json:"foldername"`
|
||||
Filename string `json:"filename"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
}
|
||||
|
||||
type Folder struct {
|
||||
Db *Database `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Folder string `json:"-"`
|
||||
Foldername string `json:"foldername"`
|
||||
}
|
||||
|
||||
func (database *Database) InsertFeedback(time int64, feedback string, header string) (error) {
|
||||
_, err := database.stmt.insertFeedback.Exec(time, feedback, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) GetRandomFiles(limit int64) ([]File, error) {
|
||||
rows, err := database.stmt.getRandomFiles.Query(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
file := File{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFilesInFolder(folder_id int64, limit int64, offset int64) ([]File, error) {
|
||||
rows, err := database.stmt.getFilesInFolder.Query(folder_id, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
file := File{
|
||||
Db: database,
|
||||
Folder_id: folder_id,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Filename, &file.Filesize, &file.Foldername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) SearchFolders(foldername string, limit int64, offset int64) ([]Folder, error) {
|
||||
rows, err := database.stmt.searchFolders.Query("%"+foldername+"%", limit, offset)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error searching folders at query " + err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
folders := make([]Folder, 0)
|
||||
for rows.Next() {
|
||||
folder := Folder{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&folder.ID, &folder.Folder, &folder.Foldername)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error scanning SearchFolders" + err.Error())
|
||||
}
|
||||
folders = append(folders, folder)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFile(id int64) (*File, error) {
|
||||
file := &File{
|
||||
Db: database,
|
||||
}
|
||||
err := database.stmt.getFile.QueryRow(id).Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (database *Database) ResetFiles() (error) {
|
||||
log.Println("[db] Reset files")
|
||||
var err error
|
||||
_, err = database.stmt.dropFiles.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = database.stmt.initFilesTable.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) ResetFolder() (error) {
|
||||
log.Println("[db] Reset folders")
|
||||
var err error
|
||||
_, err = database.stmt.dropFolder.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = database.stmt.initFoldersTable.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) Walk(root string, pattern []string) (error) {
|
||||
patternDict := make(map[string]bool)
|
||||
for _, v := range pattern {
|
||||
patternDict[v] = true
|
||||
}
|
||||
log.Println("[db] Walk", root, patternDict)
|
||||
return filepath.Walk(root, func (path string, info os.FileInfo, err error) (error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check pattern
|
||||
ext := filepath.Ext(info.Name())
|
||||
if _, ok := patternDict[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// insert file, folder will aut created
|
||||
err = database.Insert(path, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *File) Path() (string, error) {
|
||||
folder, err := f.Db.GetFolder(f.Folder_id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(folder.Folder, f.Filename), nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFolder(folderId int64) (*Folder, error) {
|
||||
folder := &Folder{
|
||||
Db: database,
|
||||
}
|
||||
err := database.stmt.getFolder.QueryRow(folderId).Scan(&folder.Folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (database *Database) SearchFiles(filename string, limit int64, offset int64) ([]File, error) {
|
||||
rows, err := database.stmt.searchFiles.Query("%"+filename+"%", limit, offset)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error searching files at query " + err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
var file File = File{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error scanning SearchFiles " + err.Error())
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, errors.New("Error scanning SearchFiles exit without full result" + err.Error())
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) FindFolder(folder string) (int64, error) {
|
||||
var id int64
|
||||
err := database.stmt.findFolder.QueryRow(folder).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (database *Database) InsertFolder(folder string) (int64, error) {
|
||||
result, err := database.stmt.insertFolder.Exec(folder, filepath.Base(folder))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lastInsertId, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return lastInsertId, nil
|
||||
}
|
||||
|
||||
func (database *Database) InsertFile(folderId int64, filename string, filesize int64) (error) {
|
||||
_, err := database.stmt.insertFile.Exec(folderId, filename, filesize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) Insert(path string, filesize int64) (error) {
|
||||
folder, filename := filepath.Split(path)
|
||||
folderId, err := database.FindFolder(folder)
|
||||
if err != nil {
|
||||
folderId, err = database.InsertFolder(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = database.InsertFile(folderId, filename, filesize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPreparedStatement(sqlConn *sql.DB) (*Stmt, error) {
|
||||
var err error
|
||||
|
||||
stmt := &Stmt{}
|
||||
|
||||
// init files table
|
||||
stmt.initFilesTable, err = sqlConn.Prepare(initFilesTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init folders table
|
||||
stmt.initFoldersTable, err = sqlConn.Prepare(initFoldersTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init feedbacks tables
|
||||
stmt.initFeedbacksTable, err = sqlConn.Prepare(initFeedbacksTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// run init statement
|
||||
_, err = stmt.initFilesTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFoldersTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFeedbacksTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insert folder statement
|
||||
stmt.insertFolder, err = sqlConn.Prepare(insertFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init findFolder statement
|
||||
stmt.findFolder, err = sqlConn.Prepare(findFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertFile stmt
|
||||
stmt.insertFile, err = sqlConn.Prepare(insertFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init searchFile stmt
|
||||
stmt.searchFiles, err = sqlConn.Prepare(searchFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFolder stmt
|
||||
stmt.getFolder, err = sqlConn.Prepare(getFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init dropFolder stmt
|
||||
stmt.dropFolder, err = sqlConn.Prepare(dropFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init dropFiles stmt
|
||||
stmt.dropFiles, err = sqlConn.Prepare(dropFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFile stmt
|
||||
stmt.getFile, err = sqlConn.Prepare(getFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init searchFolder stmt
|
||||
stmt.searchFolders, err = sqlConn.Prepare(searchFoldersQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFilesInFolder stmt
|
||||
stmt.getFilesInFolder, err = sqlConn.Prepare(getFilesInFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getRandomFiles
|
||||
stmt.getRandomFiles, err = sqlConn.Prepare(getRandomFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertFeedback
|
||||
stmt.insertFeedback, err = sqlConn.Prepare(insertFeedbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stmt, err
|
||||
}
|
||||
|
||||
func NewDatabase(dbName string) (*Database, error) {
|
||||
var err error
|
||||
|
||||
// open database
|
||||
sqlConn, err := sql.Open("sqlite3", dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// prepare statement
|
||||
stmt, err := NewPreparedStatement(sqlConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// new database
|
||||
database := &Database{
|
||||
sqlConn: sqlConn,
|
||||
stmt: stmt,
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package tmpfs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Tmpfs struct {
|
||||
record map[string]int64
|
||||
Config TmpfsConfig
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) GetObjFilePath(id int64, configName string) (string) {
|
||||
return filepath.Join(tmpfs.Config.Root, strconv.FormatInt(id, 10) + "." + configName + ".ogg")
|
||||
}
|
||||
|
||||
type TmpfsConfig struct {
|
||||
FileLifeTime int64 `json:"file_life_time"`
|
||||
CleanerInternal int64 `json:"cleaner_internal"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
func NewTmpfsConfig() (*TmpfsConfig) {
|
||||
config := &TmpfsConfig{}
|
||||
return config
|
||||
}
|
||||
|
||||
func NewTmpfs(config TmpfsConfig) *Tmpfs {
|
||||
tmpfs := &Tmpfs{
|
||||
record: make(map[string]int64),
|
||||
Config: config,
|
||||
}
|
||||
tmpfs.wg.Add(1)
|
||||
go tmpfs.Cleaner()
|
||||
return tmpfs
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Record(filename string) {
|
||||
tmpfs.record[filename] = time.Now().Unix()
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Exits(filename string) (bool) {
|
||||
_, ok := tmpfs.record[filename]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Cleaner() {
|
||||
var err error
|
||||
for {
|
||||
now := time.Now().Unix()
|
||||
for key, value := range tmpfs.record {
|
||||
if now - value > tmpfs.Config.FileLifeTime {
|
||||
err = os.Remove(key)
|
||||
if err != nil {
|
||||
log.Println("[tmpfs] Failed to remove file", err)
|
||||
}
|
||||
log.Println("[tmpfs] Deleted file", key)
|
||||
delete(tmpfs.record, key)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
14
main.go
14
main.go
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"msw-open-music/internal/pkg/api"
|
||||
"msw-open-music/internal/pkg/tmpfs"
|
||||
"msw-open-music/pkg/api"
|
||||
"msw-open-music/pkg/commonconfig"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -15,16 +15,11 @@ func init() {
|
||||
flag.StringVar(&ConfigFilePath, "config", "config.json", "backend config file path")
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
APIConfig api.APIConfig `json:"api"`
|
||||
TmpfsConfig tmpfs.TmpfsConfig `json:"tmpfs"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
flag.Parse()
|
||||
|
||||
config := Config{}
|
||||
config := commonconfig.Config{}
|
||||
configFile, err := os.Open(ConfigFilePath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -35,14 +30,13 @@ func main() {
|
||||
}
|
||||
configFile.Close()
|
||||
|
||||
api, err := api.NewAPI(config.APIConfig, config.TmpfsConfig)
|
||||
api, err := api.NewAPI(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Println("Starting",
|
||||
config.APIConfig.DatabaseName,
|
||||
config.APIConfig.Addr,
|
||||
config.APIConfig.Token,
|
||||
)
|
||||
log.Fatal(api.Server.ListenAndServe())
|
||||
}
|
||||
|
||||
109
pkg/api/api.go
Normal file
109
pkg/api/api.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gorilla/sessions"
|
||||
"msw-open-music/pkg/commonconfig"
|
||||
"msw-open-music/pkg/database"
|
||||
"msw-open-music/pkg/tmpfs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Db *database.Database
|
||||
Server http.Server
|
||||
APIConfig commonconfig.APIConfig
|
||||
Tmpfs *tmpfs.Tmpfs
|
||||
store *sessions.CookieStore
|
||||
defaultSessionName string
|
||||
}
|
||||
|
||||
func NewAPI(config commonconfig.Config) (*API, error) {
|
||||
var err error
|
||||
|
||||
apiConfig := config.APIConfig
|
||||
tmpfsConfig := config.TmpfsConfig
|
||||
|
||||
db, err := database.NewDatabase(apiConfig.DatabaseName, apiConfig.SingleThread)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore([]byte(config.APIConfig.SECRET))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
apiMux := http.NewServeMux()
|
||||
|
||||
api := &API{
|
||||
Db: db,
|
||||
Server: http.Server{
|
||||
Addr: apiConfig.Addr,
|
||||
Handler: mux,
|
||||
},
|
||||
APIConfig: apiConfig,
|
||||
store: store,
|
||||
defaultSessionName: "msw-open-music",
|
||||
}
|
||||
api.Tmpfs = tmpfs.NewTmpfs(tmpfsConfig)
|
||||
|
||||
// mount api
|
||||
apiMux.HandleFunc("/hello", api.HandleOK)
|
||||
apiMux.HandleFunc("/get_file", api.HandleGetFile)
|
||||
apiMux.HandleFunc("/get_file_direct", api.HandleGetFileDirect)
|
||||
apiMux.HandleFunc("/search_files", api.HandleSearchFiles)
|
||||
apiMux.HandleFunc("/search_folders", api.HandleSearchFolders)
|
||||
apiMux.HandleFunc("/get_files_in_folder", api.HandleGetFilesInFolder)
|
||||
apiMux.HandleFunc("/get_random_files", api.HandleGetRandomFiles)
|
||||
apiMux.HandleFunc("/get_random_files_with_tag", api.HandleGetRandomFilesWithTag)
|
||||
apiMux.HandleFunc("/get_file_stream", api.HandleGetFileStream)
|
||||
apiMux.HandleFunc("/get_ffmpeg_config_list", api.HandleGetFfmpegConfigs)
|
||||
apiMux.HandleFunc("/get_file_info", api.HandleGetFileInfo)
|
||||
apiMux.HandleFunc("/get_file_ffprobe_info", api.HandleGetFileFfprobeInfo)
|
||||
apiMux.HandleFunc("/get_file_stream_direct", api.HandleGetFileStreamDirect)
|
||||
apiMux.HandleFunc("/get_file_avatar", api.HandelGetFileAvatar)
|
||||
apiMux.HandleFunc("/prepare_file_stream_direct", api.HandlePrepareFileStreamDirect)
|
||||
apiMux.HandleFunc("/delete_file", api.HandleDeleteFile)
|
||||
apiMux.HandleFunc("/update_filename", api.HandleUpdateFilename)
|
||||
apiMux.HandleFunc("/reset_filename", api.HandleResetFilename)
|
||||
apiMux.HandleFunc("/reset_foldername", api.HandleResetFoldername)
|
||||
// feedback
|
||||
apiMux.HandleFunc("/feedback", api.HandleFeedback)
|
||||
apiMux.HandleFunc("/get_feedbacks", api.HandleGetFeedbacks)
|
||||
apiMux.HandleFunc("/delete_feedback", api.HandleDeleteFeedback)
|
||||
// user
|
||||
apiMux.HandleFunc("/login", api.HandleLogin)
|
||||
apiMux.HandleFunc("/register", api.HandleRegister)
|
||||
apiMux.HandleFunc("/logout", api.HandleLoginAsAnonymous)
|
||||
apiMux.HandleFunc("/get_user_info", api.HandleGetUserInfo)
|
||||
apiMux.HandleFunc("/get_users", api.HandleGetUsers)
|
||||
apiMux.HandleFunc("/update_user_active", api.HandleUpdateUserActive)
|
||||
apiMux.HandleFunc("/update_username", api.HandleUpdateUsername)
|
||||
apiMux.HandleFunc("/update_user_password", api.HandleUpdateUserPassword)
|
||||
// tag
|
||||
apiMux.HandleFunc("/get_tags", api.HandleGetTags)
|
||||
apiMux.HandleFunc("/get_tag_info", api.HandleGetTagInfo)
|
||||
apiMux.HandleFunc("/insert_tag", api.HandleInsertTag)
|
||||
apiMux.HandleFunc("/update_tag", api.HandleUpdateTag)
|
||||
apiMux.HandleFunc("/put_tag_on_file", api.HandlePutTagOnFile)
|
||||
apiMux.HandleFunc("/get_tags_on_file", api.HandleGetTagsOnFile)
|
||||
apiMux.HandleFunc("/delete_tag_on_file", api.HandleDeleteTagOnFile)
|
||||
apiMux.HandleFunc("/delete_tag", api.HandleDeleteTag)
|
||||
// folder
|
||||
apiMux.HandleFunc("/update_foldername", api.HandleUpdateFoldername)
|
||||
// review
|
||||
apiMux.HandleFunc("/insert_review", api.HandleInsertReview)
|
||||
apiMux.HandleFunc("/get_reviews_on_file", api.HandleGetReviewsOnFile)
|
||||
apiMux.HandleFunc("/get_review", api.HandleGetReview)
|
||||
apiMux.HandleFunc("/update_review", api.HandleUpdateReview)
|
||||
apiMux.HandleFunc("/delete_review", api.HandleDeleteReview)
|
||||
apiMux.HandleFunc("/get_reviews_by_user", api.HandleGetReviewsByUser)
|
||||
// statistic
|
||||
apiMux.HandleFunc("/record_playback", api.HandleRecordPlayback)
|
||||
// database
|
||||
apiMux.HandleFunc("/walk", api.HandleWalk)
|
||||
apiMux.HandleFunc("/reset", api.HandleReset)
|
||||
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api.PermissionMiddleware(apiMux)))
|
||||
mux.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("web/build"))))
|
||||
|
||||
return api, nil
|
||||
}
|
||||
17
pkg/api/check.go
Normal file
17
pkg/api/check.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (api *API) CheckLimit(w http.ResponseWriter, r *http.Request, limit int64) error {
|
||||
if limit <= 0 || limit > 10 {
|
||||
log.Println("[api] [Warning] Limit error", limit)
|
||||
err := errors.New(`"limit" can't be zero or more than 10`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
pkg/api/handle_avatar.go
Normal file
108
pkg/api/handle_avatar.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (api *API) HandelGetFileAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
if len(ids) == 0 {
|
||||
err = errors.New(`parameter "id" can't be empty`)
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
file, err := api.Db.GetFile(int64(id))
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
log.Println("[api] Get avatar of file", path)
|
||||
buff := make([]byte, 0)
|
||||
cache := bytes.NewBuffer(buff)
|
||||
cmd := exec.Command("ffmpeg", "-i", path, "-c:v", "libwebp_anim", "-update", "1", "-frames:v", "1", "-f", "image2pipe", "-")
|
||||
cmd.Stdout = cache
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleGetAlternativeFileAvatar(w, r, file)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
io.Copy(w, cache)
|
||||
}
|
||||
|
||||
func (api *API) HandleGetAlternativeFileAvatar(w http.ResponseWriter, r *http.Request, f *database.File) {
|
||||
var err error
|
||||
dir, err := f.Dir()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
log.Println("[api] Get alternative avatar in dir", dir)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
avatar, err := findAvatarFile(files)
|
||||
avatarPath := path.Join(dir, avatar)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("ffmpeg", "-i", avatarPath, "-c:v", "libwebp_anim", "-f", "image2pipe", "-")
|
||||
cmd.Stdout = w
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func findAvatarFile(files []os.DirEntry) (string, error) {
|
||||
for _, file := range files {
|
||||
if isAvatarType(file.Name()) {
|
||||
return file.Name(), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Cannot find avatar file")
|
||||
}
|
||||
|
||||
var avatarFileTypes = []string{
|
||||
".jpg",
|
||||
".png",
|
||||
}
|
||||
|
||||
func isAvatarType(filename string) bool {
|
||||
for _, t := range avatarFileTypes {
|
||||
if strings.HasSuffix(strings.ToLower(filename), t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
26
pkg/api/handle_common.go
Normal file
26
pkg/api/handle_common.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (api *API) HandleStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
s := &Status{
|
||||
Status: status,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(s)
|
||||
}
|
||||
|
||||
var ok Status = Status{
|
||||
Status: "OK",
|
||||
}
|
||||
|
||||
func (api *API) HandleOK(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(&ok)
|
||||
}
|
||||
72
pkg/api/handle_database_manage.go
Normal file
72
pkg/api/handle_database_manage.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type WalkRequest struct {
|
||||
Root string `json:"root"`
|
||||
Pattern []string `json:"pattern"`
|
||||
TagIDs []int64 `json:"tag_ids"`
|
||||
}
|
||||
|
||||
func (api *API) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
|
||||
log.Println("[api] Reset database")
|
||||
|
||||
// reset
|
||||
err = api.Db.ResetFiles()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
err = api.Db.ResetFolder()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleStatus(w, r, "Database reseted")
|
||||
}
|
||||
|
||||
func (api *API) HandleWalk(w http.ResponseWriter, r *http.Request) {
|
||||
walkRequest := &WalkRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(walkRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check root empty
|
||||
if walkRequest.Root == "" {
|
||||
api.HandleErrorString(w, r, `key "root" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
// check pattern empty
|
||||
if len(walkRequest.Pattern) == 0 {
|
||||
api.HandleErrorString(w, r, `"[]pattern" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
// get userID
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Walk", walkRequest.Root, walkRequest.Pattern, walkRequest.TagIDs)
|
||||
|
||||
// walk
|
||||
err = api.Db.Walk(walkRequest.Root, walkRequest.Pattern, walkRequest.TagIDs, userID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleStatus(w, r, "Database udpated")
|
||||
}
|
||||
42
pkg/api/handle_error.go
Normal file
42
pkg/api/handle_error.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotLoggedIn = errors.New("not logged in")
|
||||
ErrNotAdmin = errors.New("not admin")
|
||||
ErrEmpty = errors.New("Empty field detected, please fill in all fields")
|
||||
ErrAnonymous = errors.New("Anonymous user detected, please login")
|
||||
ErrNotActive = errors.New("User is not active")
|
||||
ErrWrongPassword = errors.New("Wrong password")
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (api *API) HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
api.HandleErrorString(w, r, err.Error())
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorCode(w http.ResponseWriter, r *http.Request, err error, code int) {
|
||||
api.HandleErrorStringCode(w, r, err.Error(), code)
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorString(w http.ResponseWriter, r *http.Request, errorString string) {
|
||||
api.HandleErrorStringCode(w, r, errorString, 500)
|
||||
}
|
||||
|
||||
func (api *API) HandleErrorStringCode(w http.ResponseWriter, r *http.Request, errorString string, code int) {
|
||||
log.Println("[api] [Error]", code, errorString)
|
||||
errStatus := &Error{
|
||||
Error: errorString,
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(errStatus)
|
||||
}
|
||||
95
pkg/api/handle_feedback.go
Normal file
95
pkg/api/handle_feedback.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FeedbackRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (api *API) HandleFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
feedbackRequest := &FeedbackRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(feedbackRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty feedback
|
||||
if feedbackRequest.Content == "" {
|
||||
api.HandleErrorString(w, r, `"feedback" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Feedback", feedbackRequest.Content)
|
||||
|
||||
headerBuff := &bytes.Buffer{}
|
||||
err = r.Header.Write(headerBuff)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
header := headerBuff.String()
|
||||
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.InsertFeedback(time.Now().Unix(), feedbackRequest.Content, userID, header)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type GetFeedbacksResponse struct {
|
||||
Feedbacks []*database.Feedback `json:"feedbacks"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFeedbacks(w http.ResponseWriter, r *http.Request) {
|
||||
feedbacks, err := api.Db.GetFeedbacks()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &GetFeedbacksResponse{
|
||||
Feedbacks: feedbacks,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteFeedbackRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
req := &DeleteFeedbackRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.DeleteFeedback(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
29
pkg/api/handle_ffmpeg_config.go
Normal file
29
pkg/api/handle_ffmpeg_config.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/commonconfig"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (api *API) GetFfmpegConfig(configName string) (commonconfig.FfmpegConfig, bool) {
|
||||
ffmpegConfig := commonconfig.FfmpegConfig{}
|
||||
for _, f := range api.APIConfig.FfmpegConfigList {
|
||||
if f.Name == configName {
|
||||
ffmpegConfig = f
|
||||
}
|
||||
}
|
||||
if ffmpegConfig.Name == "" {
|
||||
return ffmpegConfig, false
|
||||
}
|
||||
return ffmpegConfig, true
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFfmpegConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[api] Get ffmpeg config list")
|
||||
ffmpegConfigList := &commonconfig.FfmpegConfigList{
|
||||
FfmpegConfigList: api.APIConfig.FfmpegConfigList,
|
||||
}
|
||||
json.NewEncoder(w).Encode(&ffmpegConfigList)
|
||||
}
|
||||
165
pkg/api/handle_get_file_info.go
Normal file
165
pkg/api/handle_get_file_info.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type GetFileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
getFileRequest := &GetFileRequest{
|
||||
ID: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFileRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if getFileRequest.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get file info", getFileRequest.ID)
|
||||
|
||||
file, err := api.Db.GetFile(getFileRequest.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(file)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFileFfprobeInfo(w http.ResponseWriter, r *http.Request) {
|
||||
getFileRequest := &GetFileRequest {
|
||||
ID: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFileRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if getFileRequest.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get file Ffprobe info", getFileRequest.ID)
|
||||
|
||||
file, err := api.Db.GetFile(getFileRequest.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("ffprobe", "-i", path, "-hide_banner")
|
||||
cmd.Stderr = w
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// /api/v1/get_file?id=123
|
||||
// get raw file with io.Copy method
|
||||
func (api *API) HandleGetFile(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
_id, err := strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
id := int64(_id)
|
||||
|
||||
// check empty
|
||||
if id < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := api.Db.GetFile(id)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get pipe raw file", path)
|
||||
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
io.Copy(w, src)
|
||||
}
|
||||
|
||||
// /get_file_direct?id=1
|
||||
// get raw file with http.ServeFile method
|
||||
func (api *API) HandleGetFileDirect(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
if len(ids) == 0 {
|
||||
api.HandleErrorString(w, r, `parameter "id" can't be empty`)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
api.HandleErrorString(w, r, `parameter "id" should be an integer`)
|
||||
return
|
||||
}
|
||||
file, err := api.Db.GetFile(int64(id))
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// set header for filename
|
||||
filename := file.Filename
|
||||
// encode filename to URL
|
||||
filename = url.PathEscape(filename)
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+filename)
|
||||
|
||||
log.Println("[api] Get direct raw file", path)
|
||||
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
52
pkg/api/handle_get_files_in_folder.go
Normal file
52
pkg/api/handle_get_files_in_folder.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GetFilesInFolderRequest struct {
|
||||
Folder_id int64 `json:"folder_id"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetFilesInFolderResponse struct {
|
||||
Files *[]database.File `json:"files"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetFilesInFolder(w http.ResponseWriter, r *http.Request) {
|
||||
getFilesInFolderRequest := &GetFilesInFolderRequest{
|
||||
Folder_id: -1,
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(getFilesInFolderRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empyt
|
||||
if getFilesInFolderRequest.Folder_id < 0 {
|
||||
api.HandleErrorString(w, r, `"folder_id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
|
||||
files, folder, err := api.Db.GetFilesInFolder(getFilesInFolderRequest.Folder_id, getFilesInFolderRequest.Limit, getFilesInFolderRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
getFilesInFolderResponse := &GetFilesInFolderResponse{
|
||||
Files: &files,
|
||||
Folder: folder,
|
||||
}
|
||||
|
||||
log.Println("[api] Get files in folder", getFilesInFolderRequest.Folder_id)
|
||||
|
||||
json.NewEncoder(w).Encode(getFilesInFolderResponse)
|
||||
}
|
||||
51
pkg/api/handle_get_random_files.go
Normal file
51
pkg/api/handle_get_random_files.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GetRandomFilesResponse struct {
|
||||
Files *[]database.File `json:"files"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetRandomFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := api.Db.GetRandomFiles(10)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
getRandomFilesResponse := &GetRandomFilesResponse{
|
||||
Files: &files,
|
||||
}
|
||||
log.Println("[api] Get random files")
|
||||
json.NewEncoder(w).Encode(getRandomFilesResponse)
|
||||
}
|
||||
|
||||
type GetRandomFilesWithTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetRandomFilesWithTag(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetRandomFilesWithTagRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := api.Db.GetRandomFilesWithTag(req.ID, 10)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
getRandomFilesResponse := &GetRandomFilesResponse{
|
||||
Files: &files,
|
||||
}
|
||||
|
||||
log.Println("[api] Get random files with tag", req.ID)
|
||||
json.NewEncoder(w).Encode(getRandomFilesResponse)
|
||||
}
|
||||
77
pkg/api/handle_manage_file.go
Normal file
77
pkg/api/handle_manage_file.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type DeleteFileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleDeleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
req := &DeleteFileRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] delete file", req.ID)
|
||||
|
||||
err = api.Db.DeleteFile(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type UpdateFilenameRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateFilename(w http.ResponseWriter, r *http.Request) {
|
||||
req := &UpdateFilenameRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] update filename", req.ID, req.Filename)
|
||||
|
||||
err = api.Db.UpdateFilename(req.ID, req.Filename)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type ResetFilenameRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleResetFilename(w http.ResponseWriter, r *http.Request) {
|
||||
req := &ResetFilenameRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] reset filename", req.ID)
|
||||
|
||||
err = api.Db.ResetFilename(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
54
pkg/api/handle_manage_folder.go
Normal file
54
pkg/api/handle_manage_folder.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ResetFoldernameRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleResetFoldername(w http.ResponseWriter, r *http.Request) {
|
||||
req := &ResetFoldernameRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Reset foldername folderID", req.ID)
|
||||
|
||||
err = api.Db.ResetFoldername(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type UpdateFoldernameRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Foldername string `json:"foldername"`
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateFoldername(w http.ResponseWriter, r *http.Request) {
|
||||
req := &UpdateFoldernameRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Update foldername folderID", req.ID, req.Foldername)
|
||||
|
||||
err = api.Db.UpdateFoldername(req.ID, req.Foldername)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
215
pkg/api/handle_review.go
Normal file
215
pkg/api/handle_review.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// review.FileId, review.Content
|
||||
func (api *API) HandleInsertReview(w http.ResponseWriter, r *http.Request) {
|
||||
review := &database.Review{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(review)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
review.UserId, err = api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Insert review by", review.UserId, review.Content)
|
||||
|
||||
review.CreatedAt = time.Now().Unix()
|
||||
|
||||
err = api.Db.InsertReview(review)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type GetReviewsOnFileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type GetReviewsOnFileResponse struct {
|
||||
Reviews []*database.Review `json:"reviews"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetReviewsOnFile(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetReviewsOnFileRequest{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
reviews, err := api.Db.GetReviewsOnFile(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get reviews on fileID", req.ID)
|
||||
|
||||
resp := &GetReviewsOnFileResponse{
|
||||
Reviews: reviews,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type GetReviewRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type GetReviewResponse struct {
|
||||
Review *database.Review `json:"review"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetReview(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetReviewRequest{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
review, err := api.Db.GetReview(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get review ID", req.ID)
|
||||
|
||||
ret := &GetReviewResponse{
|
||||
Review: review,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(ret)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) CheckUserCanModifyReview(w http.ResponseWriter, r *http.Request, reviewID int64) error {
|
||||
review, err := api.Db.GetReview(reviewID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userLevel := api.GetUserLevel(r)
|
||||
if userLevel != database.RoleAdmin {
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if review.UserId != userID {
|
||||
return errors.New("you are not allowed to modify this review")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateReview(w http.ResponseWriter, r *http.Request) {
|
||||
req := &database.Review{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.CheckUserCanModifyReview(w, r, req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Update review", req.ID, req.Content)
|
||||
|
||||
req.UpdatedAt = time.Now().Unix()
|
||||
|
||||
err = api.Db.UpdateReview(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type DeleteReviewRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleDeleteReview(w http.ResponseWriter, r *http.Request) {
|
||||
req := &DeleteReviewRequest{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.CheckUserCanModifyReview(w, r, req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Delete review ID", req.ID)
|
||||
|
||||
err = api.Db.DeleteReview(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type GetReviewsByUserRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetReviewsByUser(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetReviewsByUserRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
reviews, err := api.Db.GetReviewsByUser(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(reviews)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
48
pkg/api/handle_search_files.go
Normal file
48
pkg/api/handle_search_files.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SearchFilesRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type SearchFilesResponse struct {
|
||||
Files []database.File `json:"files"`
|
||||
}
|
||||
|
||||
func (api *API) HandleSearchFiles(w http.ResponseWriter, r *http.Request) {
|
||||
searchFilesRequest := &SearchFilesRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(searchFilesRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if searchFilesRequest.Filename == "" {
|
||||
api.HandleErrorString(w, r, `"filename" can't be empty`)
|
||||
return
|
||||
}
|
||||
if api.CheckLimit(w, r, searchFilesRequest.Limit) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
searchFilesResponse := &SearchFilesResponse{}
|
||||
|
||||
searchFilesResponse.Files, err = api.Db.SearchFiles(searchFilesRequest.Filename, searchFilesRequest.Limit, searchFilesRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Search files", searchFilesRequest.Filename, searchFilesRequest.Limit, searchFilesRequest.Offset)
|
||||
|
||||
json.NewEncoder(w).Encode(searchFilesResponse)
|
||||
}
|
||||
48
pkg/api/handle_search_folders.go
Normal file
48
pkg/api/handle_search_folders.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SearchFoldersRequest struct {
|
||||
Foldername string `json:"foldername"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
type SearchFoldersResponse struct {
|
||||
Folders []database.Folder `json:"folders"`
|
||||
}
|
||||
|
||||
func (api *API) HandleSearchFolders(w http.ResponseWriter, r *http.Request) {
|
||||
searchFoldersRequest := &SearchFoldersRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(searchFoldersRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if searchFoldersRequest.Foldername == "" {
|
||||
api.HandleErrorString(w, r, `"foldername" can't be empty`)
|
||||
return
|
||||
}
|
||||
if api.CheckLimit(w, r, searchFoldersRequest.Limit) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
searchFoldersResponse := &SearchFoldersResponse{}
|
||||
|
||||
searchFoldersResponse.Folders, err = api.Db.SearchFolders(searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Search folders", searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
|
||||
|
||||
json.NewEncoder(w).Encode(searchFoldersResponse)
|
||||
}
|
||||
51
pkg/api/handle_stat.go
Normal file
51
pkg/api/handle_stat.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RecordPlaybackRequest struct {
|
||||
Playback database.Playback `json:"playback"`
|
||||
}
|
||||
|
||||
func (api *API) HandleRecordPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
recordPlaybackRequest := &RecordPlaybackRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(recordPlaybackRequest)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
recordPlaybackRequest.Playback.Time = time.Now()
|
||||
recordPlaybackRequest.Playback.UserID, err = api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
if err == ErrNotLoggedIn {
|
||||
user, err := api.Db.LoginAsAnonymous()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
recordPlaybackRequest.Playback.UserID = user.ID
|
||||
} else {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[api] Record playback history",
|
||||
recordPlaybackRequest.Playback.UserID,
|
||||
recordPlaybackRequest.Playback.FileID,
|
||||
recordPlaybackRequest.Playback.Duration,
|
||||
recordPlaybackRequest.Playback.Method)
|
||||
|
||||
err = api.Db.RecordPlayback(recordPlaybackRequest.Playback)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
211
pkg/api/handle_stream.go
Normal file
211
pkg/api/handle_stream.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (api *API) CheckGetFileStream(w http.ResponseWriter, r *http.Request) error {
|
||||
var err error
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
if len(ids) == 0 {
|
||||
err = errors.New(`parameter "id" can't be empty`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
_, err = strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
err = errors.New(`parameter "id" should be an integer`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
configs := q["config"]
|
||||
if len(configs) == 0 {
|
||||
err = errors.New(`parameter "config" can't be empty`)
|
||||
api.HandleError(w, r, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// /get_file_stream?id=1&config=ffmpeg_config_name
|
||||
func (api *API) HandleGetFileStream(w http.ResponseWriter, r *http.Request) {
|
||||
err := api.CheckGetFileStream(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
configs := q["config"]
|
||||
configName := configs[0]
|
||||
file, err := api.Db.GetFile(int64(id))
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Stream file", path, configName)
|
||||
|
||||
ffmpegConfig, ok := api.GetFfmpegConfig(configName)
|
||||
if !ok {
|
||||
api.HandleErrorStringCode(w, r, `ffmpeg config not found`, 404)
|
||||
return
|
||||
}
|
||||
|
||||
// set headers for filename
|
||||
filename := file.Filename + "." + ffmpegConfig.Name + "." + ffmpegConfig.Format
|
||||
filename = url.PathEscape(filename)
|
||||
// replace invalid characters
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+filename)
|
||||
|
||||
args := strings.Split(ffmpegConfig.Args, " ")
|
||||
startArgs := []string{"-threads", strconv.FormatInt(api.APIConfig.FfmpegThreads, 10), "-i", path}
|
||||
endArgs := []string{"-f", ffmpegConfig.Format, "-"}
|
||||
ffmpegArgs := append(startArgs, args...)
|
||||
ffmpegArgs = append(ffmpegArgs, endArgs...)
|
||||
cmd := exec.Command("ffmpeg", ffmpegArgs...)
|
||||
cmd.Stdout = w
|
||||
// cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type PrepareFileStreamDirectRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
ConfigName string `json:"config_name"`
|
||||
}
|
||||
|
||||
type PrepareFileStreamDirectResponse struct {
|
||||
File *database.File `json:"file"`
|
||||
}
|
||||
|
||||
// /prepare_file_stream_direct?id=1&config=ffmpeg_config_name
|
||||
func (api *API) HandlePrepareFileStreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
prepareFileStreamDirectRequst := &PrepareFileStreamDirectRequest{
|
||||
ID: -1,
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(prepareFileStreamDirectRequst)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if prepareFileStreamDirectRequst.ID < 0 {
|
||||
api.HandleErrorString(w, r, `"id" can't be none or negative`)
|
||||
return
|
||||
}
|
||||
if prepareFileStreamDirectRequst.ConfigName == "" {
|
||||
api.HandleErrorString(w, r, `"config_name" can't be empty`)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := api.Db.GetFile(prepareFileStreamDirectRequst.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
srcPath, err := file.Path()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Prepare stream direct file", srcPath, prepareFileStreamDirectRequst.ConfigName)
|
||||
ffmpegConfig, ok := api.GetFfmpegConfig(prepareFileStreamDirectRequst.ConfigName)
|
||||
if !ok {
|
||||
api.HandleErrorStringCode(w, r, `ffmpeg config not found`, 404)
|
||||
return
|
||||
}
|
||||
objPath := api.Tmpfs.GetObjFilePath(prepareFileStreamDirectRequst.ID, ffmpegConfig)
|
||||
|
||||
// check obj file exists
|
||||
exists := api.Tmpfs.Exits(objPath)
|
||||
if !exists {
|
||||
// lock the object
|
||||
api.Tmpfs.Lock(objPath)
|
||||
|
||||
args := strings.Split(ffmpegConfig.Args, " ")
|
||||
startArgs := []string{"-threads", strconv.FormatInt(api.APIConfig.FfmpegThreads, 10), "-i", srcPath}
|
||||
endArgs := []string{"-y", objPath}
|
||||
ffmpegArgs := append(startArgs, args...)
|
||||
ffmpegArgs = append(ffmpegArgs, endArgs...)
|
||||
cmd := exec.Command("ffmpeg", ffmpegArgs...)
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.Tmpfs.Record(objPath)
|
||||
api.Tmpfs.Unlock(objPath)
|
||||
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(objPath)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
file.Filesize = fileInfo.Size()
|
||||
|
||||
prepareFileStreamDirectResponse := &PrepareFileStreamDirectResponse{
|
||||
File: file,
|
||||
}
|
||||
json.NewEncoder(w).Encode(prepareFileStreamDirectResponse)
|
||||
}
|
||||
|
||||
// /get_file_stream_direct?id=1&config=ffmpeg_config_name
|
||||
// return converted file with http.ServeFile method
|
||||
func (api *API) HandleGetFileStreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
err := api.CheckGetFileStream(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
ids := q["id"]
|
||||
id, err := strconv.Atoi(ids[0])
|
||||
configs := q["config"]
|
||||
configName := configs[0]
|
||||
|
||||
ffmpegConfig, ok := api.GetFfmpegConfig(configName)
|
||||
if !ok {
|
||||
api.HandleErrorStringCode(w, r, `ffmpeg config not found`, 404)
|
||||
return
|
||||
}
|
||||
|
||||
path := api.Tmpfs.GetObjFilePath(int64(id), ffmpegConfig)
|
||||
if api.Tmpfs.Exits(path) {
|
||||
api.Tmpfs.Record(path)
|
||||
}
|
||||
|
||||
// set headers for filename
|
||||
filename := ids[0] + "." + ffmpegConfig.Name + "." + ffmpegConfig.Format
|
||||
filename = url.PathEscape(filename)
|
||||
// replace invalid characters
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+filename)
|
||||
|
||||
log.Println("[api] Get direct cached file", path)
|
||||
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
140
pkg/api/handle_tag.go
Normal file
140
pkg/api/handle_tag.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type getTagsResponse struct {
|
||||
Tags []*database.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := api.Db.GetTags()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Successfully got tags")
|
||||
|
||||
resp := &getTagsResponse{Tags: tags}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type InsertTagResponse struct {
|
||||
Tag *database.Tag `json:"tag"`
|
||||
}
|
||||
|
||||
func (api *API) HandleInsertTag(w http.ResponseWriter, r *http.Request) {
|
||||
req := &database.Tag{}
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.CreatedByUserId, err = api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := api.Db.InsertTag(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := api.Db.GetTag(tagID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &InsertTagResponse{Tag: tag}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type GetTagInfoRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type GetTagInfoResponse struct {
|
||||
Tag *database.Tag `json:"tag"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetTagInfo(w http.ResponseWriter, r *http.Request) {
|
||||
var req GetTagInfoRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := api.Db.GetTag(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &GetTagInfoResponse{Tag: tag}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateTag(w http.ResponseWriter, r *http.Request) {
|
||||
req := &database.Tag{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.UpdateTag(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type DeleteTagRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleDeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
req := &DeleteTagRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.DeleteTag(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Successfully deleted tag and its references", req.ID)
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
105
pkg/api/handle_tag_and_file.go
Normal file
105
pkg/api/handle_tag_and_file.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type PutTagOnFileRequest struct {
|
||||
TagID int64 `json:"tag_id"`
|
||||
FileID int64 `json:"file_id"`
|
||||
}
|
||||
|
||||
func (api *API) HandlePutTagOnFile(w http.ResponseWriter, r *http.Request) {
|
||||
req := &PutTagOnFileRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if req.TagID == 0 || req.FileID == 0 {
|
||||
api.HandleError(w, r, ErrEmpty)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Put tag on file request:", req, "userID:", userID)
|
||||
|
||||
api.Db.PutTagOnFile(req.TagID, req.FileID, userID)
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type GetTagsOnFileRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type GetTagsOnFileResponse struct {
|
||||
Tags []*database.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetTagsOnFile(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetTagsOnFileRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Get tags on file request:", req)
|
||||
|
||||
tags, err := api.Db.GetTagsOnFile(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &GetTagsOnFileResponse{
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteTagOnFileRequest struct {
|
||||
TagID int64 `json:"tag_id"`
|
||||
FileID int64 `json:"file_id"`
|
||||
}
|
||||
|
||||
func (api *API) HandleDeleteTagOnFile(w http.ResponseWriter, r *http.Request) {
|
||||
req := &DeleteTagOnFileRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check empty
|
||||
if req.TagID == 0 || req.FileID == 0 {
|
||||
api.HandleError(w, r, ErrEmpty)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Delete tag on file request:", req)
|
||||
|
||||
err = api.Db.DeleteTagOnFile(req.TagID, req.FileID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
326
pkg/api/handle_user.go
Normal file
326
pkg/api/handle_user.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"msw-open-music/pkg/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
User *database.User `json:"user"`
|
||||
}
|
||||
|
||||
func (api *API) HandleLoginAsAnonymous(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := api.LoginAsAnonymous(w, r)
|
||||
resp := &LoginResponse{
|
||||
User: user,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) LoginAsAnonymous(w http.ResponseWriter, r *http.Request) (*database.User, error) {
|
||||
user, err := api.Db.LoginAsAnonymous()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session, _ := api.store.Get(r, api.defaultSessionName)
|
||||
|
||||
// save session
|
||||
session.Values["userId"] = user.ID
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// return user
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (api *API) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var user *database.User
|
||||
var err error
|
||||
session, _ := api.store.Get(r, api.defaultSessionName)
|
||||
|
||||
// Get method will login current or anonymous user
|
||||
if r.Method == "GET" {
|
||||
|
||||
// if user already logged in
|
||||
if userId, ok := session.Values["userId"]; ok {
|
||||
user, err = api.Db.GetUserById(userId.(int64))
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
log.Println("[api] Warning: User not found")
|
||||
// login as anonymous user
|
||||
api.LoginAsAnonymous(w, r)
|
||||
return
|
||||
}
|
||||
log.Println("[api] User already logged in:", user)
|
||||
|
||||
} else {
|
||||
// login as anonymous user
|
||||
log.Println("[api] Login as anonymous user")
|
||||
api.LoginAsAnonymous(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
var request LoginRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&request)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Login as user", request.Username)
|
||||
|
||||
user, err = api.Db.Login(request.Username, request.Password)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// if user is not active
|
||||
if !user.Active {
|
||||
api.HandleError(w, r, ErrNotActive)
|
||||
return
|
||||
}
|
||||
|
||||
// save session
|
||||
session.Values["userId"] = user.ID
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := &LoginResponse{
|
||||
User: user,
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role int64 `json:"role"`
|
||||
}
|
||||
|
||||
func (api *API) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var request RegisterRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&request)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[api] Register user", request.Username)
|
||||
|
||||
err = api.Db.Register(request.Username, request.Password, request.Role)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
func (api *API) GetUserID(w http.ResponseWriter, r *http.Request) (int64, error) {
|
||||
session, _ := api.store.Get(r, api.defaultSessionName)
|
||||
userId, ok := session.Values["userId"]
|
||||
if !ok {
|
||||
return 0, ErrNotLoggedIn
|
||||
}
|
||||
|
||||
return userId.(int64), nil
|
||||
}
|
||||
|
||||
type GetUsersResponse struct {
|
||||
Users []*database.User `json:"users"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := api.Db.GetUsers()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ret := &GetUsersResponse{
|
||||
Users: users,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(ret)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateUserActiveRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
req := &UpdateUserActiveRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.UpdateUserActive(req.ID, req.Active)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type UpdateUsernameRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateUsername(w http.ResponseWriter, r *http.Request) {
|
||||
// middileware reject anonymous user
|
||||
|
||||
req := &UpdateUsernameRequest{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := api.Db.GetUserById(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID != userID && user.Role != database.RoleAdmin {
|
||||
api.HandleError(w, r, ErrNotAdmin)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Db.UpdateUsername(req.ID, req.Username)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
|
||||
type GetUserInfoRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type GetUserInfoResponse struct {
|
||||
User *database.User `json:"user"`
|
||||
}
|
||||
|
||||
func (api *API) HandleGetUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
req := &GetUserInfoRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := api.Db.GetUserById(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ret := &GetUserInfoResponse{
|
||||
User: user,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(ret)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateUserPasswordRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
func (api *API) HandleUpdateUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
// middleware reject anonymous user
|
||||
|
||||
req := &UpdateUserPasswordRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := api.Db.GetUserById(req.ID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := api.GetUserID(w, r)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, err := api.Db.GetUserById(userID)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != database.RoleAdmin {
|
||||
_, err := api.Db.Login(user.Username, req.OldPassword)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, ErrWrongPassword)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = api.Db.UpdateUserPassword(req.ID, req.NewPassword)
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleOK(w, r)
|
||||
}
|
||||
55
pkg/api/middleware.go
Normal file
55
pkg/api/middleware.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (api *API) PermissionMiddleware(next http.Handler) http.Handler {
|
||||
// 0 anonymous user
|
||||
// 1 admin
|
||||
// 2 normal user
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// get permission of URL
|
||||
permission, ok := api.APIConfig.Permission[r.URL.Path]
|
||||
// 0 means no permission required
|
||||
if !ok || permission == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// ger user permission level
|
||||
userLevel := api.GetUserLevel(r)
|
||||
|
||||
// admin has root (highest) permission level 1
|
||||
if userLevel == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// anonymous userLevel 0 don't have any permission
|
||||
// check permission level for other users
|
||||
if userLevel == 0 || userLevel > permission {
|
||||
api.HandleError(w, r, errors.New("No enougth permission"))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) GetUserLevel(r *http.Request) int64 {
|
||||
session, _ := api.store.Get(r, api.defaultSessionName)
|
||||
userId, ok := session.Values["userId"]
|
||||
if !ok {
|
||||
// not logined user is considered anonymous user
|
||||
return 0
|
||||
}
|
||||
|
||||
user, err := api.Db.GetUserById(userId.(int64))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return user.Role
|
||||
}
|
||||
44
pkg/commonconfig/config.go
Normal file
44
pkg/commonconfig/config.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package commonconfig
|
||||
|
||||
type Config struct {
|
||||
APIConfig APIConfig `json:"api"`
|
||||
TmpfsConfig TmpfsConfig `json:"tmpfs"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
DatabaseName string `json:"database_name"`
|
||||
SingleThread bool `json:"single_thread,default=true"`
|
||||
Addr string `json:"addr"`
|
||||
FfmpegThreads int64 `json:"ffmpeg_threads"`
|
||||
FfmpegConfigList []FfmpegConfig `json:"ffmpeg_config_list"`
|
||||
SECRET string `json:"secret"`
|
||||
Permission map[string]int64 `json:"permission"`
|
||||
}
|
||||
|
||||
type FfmpegConfigList struct {
|
||||
FfmpegConfigList []FfmpegConfig `json:"ffmpeg_config_list"`
|
||||
}
|
||||
|
||||
type FfmpegConfig struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type TmpfsConfig struct {
|
||||
FileLifeTime int64 `json:"file_life_time"`
|
||||
CleanerInternal int64 `json:"cleaner_internal"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// Constructors for Config
|
||||
|
||||
func NewAPIConfig() APIConfig {
|
||||
apiConfig := APIConfig{}
|
||||
return apiConfig
|
||||
}
|
||||
|
||||
func NewTmpfsConfig() *TmpfsConfig {
|
||||
config := &TmpfsConfig{}
|
||||
return config
|
||||
}
|
||||
63
pkg/database/database.go
Normal file
63
pkg/database/database.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
sqlConn *sql.DB
|
||||
stmt *Stmt
|
||||
singleThreadLock SingleThreadLock
|
||||
}
|
||||
|
||||
func NewSingleThreadLock(enabled bool) SingleThreadLock {
|
||||
return SingleThreadLock{
|
||||
lock: sync.Mutex{},
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
type SingleThreadLock struct {
|
||||
lock sync.Mutex
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func (stl *SingleThreadLock) Lock() {
|
||||
if stl.enabled {
|
||||
stl.lock.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stl *SingleThreadLock) Unlock() {
|
||||
if stl.enabled {
|
||||
stl.lock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func NewDatabase(dbName string, singleThread bool) (*Database, error) {
|
||||
var err error
|
||||
|
||||
// open database
|
||||
sqlConn, err := sql.Open("postgres", dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// prepare statement
|
||||
stmt, err := NewPreparedStatement(sqlConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// new database
|
||||
database := &Database{
|
||||
sqlConn: sqlConn,
|
||||
stmt: stmt,
|
||||
singleThreadLock: NewSingleThreadLock(singleThread),
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
10
pkg/database/error.go
Normal file
10
pkg/database/error.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("object not found")
|
||||
ErrTagNotFound = errors.New("tag not found")
|
||||
)
|
||||
456
pkg/database/method.go
Normal file
456
pkg/database/method.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (database *Database) GetRandomFiles(limit int64) ([]File, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getRandomFiles.Query(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
file := File{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetRandomFilesWithTag(tagID, limit int64) ([]File, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getRandomFilesWithTag.Query(tagID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
file := File{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFilesInFolder(folder_id int64, limit int64, offset int64) ([]File, string, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getFilesInFolder.Query(folder_id, limit, offset)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
folder := ""
|
||||
for rows.Next() {
|
||||
file := File{
|
||||
Db: database,
|
||||
Folder_id: folder_id,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Filename, &file.Filesize, &file.Foldername, &folder)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, folder, nil
|
||||
}
|
||||
|
||||
func (database *Database) SearchFolders(foldername string, limit int64, offset int64) ([]Folder, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.searchFolders.Query("%"+foldername+"%", limit, offset)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error searching folders at query " + err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
folders := make([]Folder, 0)
|
||||
for rows.Next() {
|
||||
folder := Folder{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&folder.ID, &folder.Folder, &folder.Foldername)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error scanning SearchFolders" + err.Error())
|
||||
}
|
||||
folders = append(folders, folder)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFile(id int64) (*File, error) {
|
||||
file := &File{
|
||||
Db: database,
|
||||
}
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
err := database.stmt.getFile.QueryRow(id).Scan(&file.ID, &file.Folder_id, &file.Realname, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (database *Database) ResetFiles() error {
|
||||
log.Println("[db] Reset files")
|
||||
var err error
|
||||
_, err = database.stmt.dropFiles.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = database.stmt.initFilesTable.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) ResetFolder() error {
|
||||
log.Println("[db] Reset folders")
|
||||
var err error
|
||||
_, err = database.stmt.dropFolder.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = database.stmt.initFoldersTable.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) Walk(root string, pattern []string, tagIDs []int64, userID int64) error {
|
||||
patternDict := make(map[string]bool)
|
||||
for _, v := range pattern {
|
||||
patternDict[v] = true
|
||||
}
|
||||
log.Println("[db] Walk", root, patternDict)
|
||||
|
||||
tags := make([]*Tag, 0)
|
||||
for _, tagID := range tagIDs {
|
||||
tag, err := database.GetTag(tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
tx, err := database.sqlConn.Begin()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
insertFolderStmt := tx.Stmt(database.stmt.insertFolder)
|
||||
insertFileStmt := tx.Stmt(database.stmt.insertFile)
|
||||
putTagOnFileStmt := tx.Stmt(database.stmt.putTagOnFile)
|
||||
findFolderStmt := tx.Stmt(database.stmt.findFolder)
|
||||
findFileStmt := tx.Stmt(database.stmt.findFile)
|
||||
|
||||
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check pattern
|
||||
ext := filepath.Ext(info.Name())
|
||||
if _, ok := patternDict[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// insert file and folder
|
||||
// fileID, err := database.Insert(path, info.Size())
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
var folderID int64
|
||||
folder, filename := filepath.Split(path)
|
||||
err = findFolderStmt.QueryRow(folder).Scan(&folderID)
|
||||
if err != nil {
|
||||
result, err := insertFolderStmt.Query(folder, filepath.Base(folder))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for result.Next() {
|
||||
err = result.Scan(&folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try find file id
|
||||
var fileID int64
|
||||
result, err := findFileStmt.Query(folderID, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for result.Next() {
|
||||
err = result.Scan(&fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// insert new file
|
||||
result, err = insertFileStmt.Query(folderID, filename, filename, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for result.Next() {
|
||||
err = result.Scan(&fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
_, err := putTagOnFileStmt.Exec(tag.ID, fileID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (database *Database) GetFolder(folderId int64) (*Folder, error) {
|
||||
folder := &Folder{
|
||||
Db: database,
|
||||
}
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
err := database.stmt.getFolder.QueryRow(folderId).Scan(&folder.Folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (database *Database) SearchFiles(filename string, limit int64, offset int64) ([]File, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.searchFiles.Query("%"+filename+"%", limit, offset)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error searching files at query " + err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
files := make([]File, 0)
|
||||
for rows.Next() {
|
||||
var file File = File{
|
||||
Db: database,
|
||||
}
|
||||
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
|
||||
if err != nil {
|
||||
return nil, errors.New("Error scanning SearchFiles " + err.Error())
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, errors.New("Error scanning SearchFiles exit without full result" + err.Error())
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (database *Database) FindFolder(folder string) (int64, error) {
|
||||
var id int64
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
err := database.stmt.findFolder.QueryRow(folder).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (database *Database) FindFile(folderId int64, filename string) (int64, error) {
|
||||
var id int64
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
err := database.stmt.findFile.QueryRow(folderId, filename).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (database *Database) InsertFolder(folder string) (int64, error) {
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.insertFolder.Exec(folder, filepath.Base(folder))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lastInsertId, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return lastInsertId, nil
|
||||
}
|
||||
|
||||
func (database *Database) InsertFile(folderId int64, filename string, filesize int64) (int64, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.insertFile.Exec(folderId, filename, filename, filesize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lastInsertId, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return lastInsertId, nil
|
||||
}
|
||||
|
||||
func (database *Database) Insert(path string, filesize int64) (int64, error) {
|
||||
folder, filename := filepath.Split(path)
|
||||
folderId, err := database.FindFolder(folder)
|
||||
if err != nil {
|
||||
folderId, err = database.InsertFolder(folder)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// if file exists, skip it
|
||||
lastInsertId, err := database.FindFile(folderId, filename)
|
||||
if err == nil {
|
||||
return lastInsertId, nil
|
||||
}
|
||||
|
||||
lastInsertId, err = database.InsertFile(folderId, filename, filesize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return lastInsertId, nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateFoldername(folderId int64, foldername string) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.updateFoldername.Exec(foldername, folderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) DeleteFile(fileId int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
// begin transaction
|
||||
tx, err := database.sqlConn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete file
|
||||
_, err = tx.Stmt(database.stmt.deleteFile).Exec(fileId)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// delete tag on file
|
||||
_, err = tx.Stmt(database.stmt.deleteFileReferenceInFileHasTag).Exec(fileId)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// delete reviews on file
|
||||
_, err = tx.Stmt(database.stmt.deleteFileReferenceInReviews).Exec(fileId)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// commit transaction
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateFilename(fileId int64, filename string) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.updateFilename.Exec(filename, fileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) ResetFilename(fileId int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.resetFilename.Exec(fileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) ResetFoldername(folderId int64) error {
|
||||
folder, err := database.GetFolder(folderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
foldername := filepath.Base(folder.Folder)
|
||||
err = database.UpdateFoldername(folderId, foldername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
49
pkg/database/method_feedback.go
Normal file
49
pkg/database/method_feedback.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package database
|
||||
|
||||
func (database *Database) InsertFeedback(time int64, content string, userID int64, header string) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.insertFeedback.Exec(time, content, userID, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) GetFeedbacks() ([]*Feedback, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getFeedbacks.Query()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
feedbacks := make([]*Feedback, 0)
|
||||
for rows.Next() {
|
||||
feedback := &Feedback{
|
||||
User: &User{},
|
||||
}
|
||||
err := rows.Scan(
|
||||
&feedback.ID, &feedback.Time, &feedback.Content, &feedback.Header,
|
||||
&feedback.User.ID, &feedback.User.Username, &feedback.User.Role, &feedback.User.Active, &feedback.User.AvatarId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feedbacks = append(feedbacks, feedback)
|
||||
}
|
||||
return feedbacks, nil
|
||||
}
|
||||
|
||||
func (database *Database) DeleteFeedback(id int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.deleteFeedback.Exec(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
pkg/database/method_playback.go
Normal file
7
pkg/database/method_playback.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package database
|
||||
|
||||
func (database *Database) RecordPlayback(playback Playback) error {
|
||||
_, err := database.stmt.recordPlaybackStmt.Exec(
|
||||
playback.UserID, playback.FileID, playback.Time, playback.Method, playback.Duration)
|
||||
return err
|
||||
}
|
||||
123
pkg/database/method_review.go
Normal file
123
pkg/database/method_review.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package database
|
||||
|
||||
func (database *Database) InsertReview(review *Review) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.insertReview.Exec(
|
||||
review.UserId,
|
||||
review.FileId,
|
||||
review.CreatedAt,
|
||||
review.Content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) GetReviewsOnFile(fileId int64) ([]*Review, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getReviewsOnFile.Query(fileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reviews := make([]*Review, 0)
|
||||
for rows.Next() {
|
||||
review := &Review{
|
||||
User: &User{},
|
||||
File: &File{},
|
||||
}
|
||||
err := rows.Scan(
|
||||
&review.ID,
|
||||
&review.CreatedAt,
|
||||
&review.UpdatedAt,
|
||||
&review.Content,
|
||||
&review.User.ID,
|
||||
&review.User.Username,
|
||||
&review.User.Role,
|
||||
&review.User.AvatarId,
|
||||
&review.File.ID,
|
||||
&review.File.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
return reviews, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetReview(reviewId int64) (*Review, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
row := database.stmt.getReview.QueryRow(reviewId)
|
||||
|
||||
review := &Review{}
|
||||
err := row.Scan(
|
||||
&review.ID,
|
||||
&review.FileId,
|
||||
&review.UserId,
|
||||
&review.CreatedAt,
|
||||
&review.UpdatedAt,
|
||||
&review.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return review, nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateReview(review *Review) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.updateReview.Exec(
|
||||
review.Content,
|
||||
review.UpdatedAt,
|
||||
review.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) DeleteReview(reviewId int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.deleteReview.Exec(reviewId)
|
||||
return err
|
||||
}
|
||||
|
||||
func (database *Database) GetReviewsByUser(userId int64) ([]*Review, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getReviewsByUser.Query(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reviews := make([]*Review, 0)
|
||||
for rows.Next() {
|
||||
review := &Review{
|
||||
User: &User{},
|
||||
File: &File{},
|
||||
}
|
||||
err := rows.Scan(
|
||||
&review.ID,
|
||||
&review.CreatedAt,
|
||||
&review.UpdatedAt,
|
||||
&review.Content,
|
||||
&review.User.ID,
|
||||
&review.User.Username,
|
||||
&review.User.Role,
|
||||
&review.User.AvatarId,
|
||||
&review.File.ID,
|
||||
&review.File.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
return reviews, nil
|
||||
}
|
||||
116
pkg/database/method_tag.go
Normal file
116
pkg/database/method_tag.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
import "errors"
|
||||
|
||||
func (database *Database) InsertTag(tag *Tag) (int64, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.insertTag.Query(tag.Name, tag.Description, tag.CreatedByUserId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
for result.Next() {
|
||||
err = result.Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetTag(id int64) (*Tag, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
tag := &Tag{CreatedByUser: &User{}}
|
||||
err := database.stmt.getTag.QueryRow(id).Scan(
|
||||
&tag.ID, &tag.Name, &tag.Description,
|
||||
&tag.CreatedByUser.ID, &tag.CreatedByUser.Username, &tag.CreatedByUser.Role, &tag.CreatedByUser.AvatarId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetTags() ([]*Tag, error) {
|
||||
tags := []*Tag{}
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getTags.Query()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
tag := &Tag{CreatedByUser: &User{}}
|
||||
err := rows.Scan(
|
||||
&tag.ID, &tag.Name, &tag.Description,
|
||||
&tag.CreatedByUser.ID, &tag.CreatedByUser.Username, &tag.CreatedByUser.Role, &tag.CreatedByUser.AvatarId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateTag(tag *Tag) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.updateTag.Exec(tag.Name, tag.Description, tag.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.New("No rows affected")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete tag and all its references in file_has_tag
|
||||
func (database *Database) DeleteTag(id int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
// begin transaction
|
||||
tx, err := database.sqlConn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete tag
|
||||
_, err = tx.Stmt(database.stmt.deleteTag).Exec(id)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// delete file_has_tag
|
||||
_, err = tx.Stmt(database.stmt.deleteTagReferenceInFileHasTag).Exec(id)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// commit transaction
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
pkg/database/method_tag_and_file.go
Normal file
48
pkg/database/method_tag_and_file.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
func (database *Database) PutTagOnFile(tagID, fileID, userID int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.putTagOnFile.Exec(tagID, fileID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) GetTagsOnFile(fileID int64) ([]*Tag, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getTagsOnFile.Query(fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]*Tag, 0)
|
||||
for rows.Next() {
|
||||
tag := &Tag{}
|
||||
err = rows.Scan(&tag.ID, &tag.Name, &tag.Description, &tag.CreatedByUserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (database *Database) DeleteTagOnFile(tagID, fileID int64) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.deleteTagOnFile.Exec(tagID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return ErrTagNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
171
pkg/database/method_user.go
Normal file
171
pkg/database/method_user.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (database *Database) Login(username string, password string) (*User, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
user := &User{}
|
||||
|
||||
// get user from database
|
||||
err := database.stmt.getUser.QueryRow(username).Scan(&user.ID, &user.Username, &user.Password, &user.Role, &user.Active, &user.AvatarId)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
// validate password
|
||||
err = database.ComparePassword(user.Password, password)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (database *Database) LoginAsAnonymous() (*User, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
user := &User{}
|
||||
|
||||
// get user from database
|
||||
err := database.stmt.getAnonymousUser.QueryRow().Scan(&user.ID, &user.Username, &user.Role, &user.AvatarId)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (database *Database) Register(username string, password string, usertype int64) error {
|
||||
countAdmin, err := database.CountAdmin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
active := false
|
||||
if countAdmin == 0 {
|
||||
active = true
|
||||
}
|
||||
|
||||
// active normal user by default
|
||||
if usertype == 2 {
|
||||
active = true
|
||||
}
|
||||
|
||||
// encrypt password
|
||||
password = database.EncryptPassword(password)
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err = database.stmt.insertUser.Exec(username, password, usertype, active, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) GetUserById(id int64) (*User, error) {
|
||||
user := &User{}
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
// get user from database
|
||||
err := database.stmt.getUserById.QueryRow(id).Scan(&user.ID, &user.Username, &user.Role, &user.Active, &user.AvatarId)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (database *Database) CountAdmin() (int64, error) {
|
||||
var count int64
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
err := database.stmt.countAdmin.QueryRow().Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (database *Database) GetUsers() ([]*User, error) {
|
||||
users := make([]*User, 0)
|
||||
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
rows, err := database.stmt.getUsers.Query()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
user := &User{}
|
||||
err = rows.Scan(&user.ID, &user.Username, &user.Role, &user.Active, &user.AvatarId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateUserActive(id int64, active bool) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.updateUserActive.Exec(active, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateUsername(id int64, username string) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
_, err := database.stmt.updateUsername.Exec(username, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) UpdateUserPassword(id int64, password string) error {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
// encrypt password
|
||||
password = database.EncryptPassword(password)
|
||||
|
||||
_, err := database.stmt.updateUserPassword.Exec(password, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (database *Database) EncryptPassword(password string) string {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
log.Println("[database] Failed to hash password, using plaintext password")
|
||||
return password
|
||||
}
|
||||
|
||||
return string(hash)
|
||||
}
|
||||
|
||||
func (database *Database) ComparePassword(hashedPassword string, plainTextPassword string) error {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainTextPassword))
|
||||
return err
|
||||
}
|
||||
782
pkg/database/sql_stmt.go
Normal file
782
pkg/database/sql_stmt.go
Normal file
@@ -0,0 +1,782 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
var initFilesTableQuery = `CREATE TABLE IF NOT EXISTS files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id),
|
||||
realname TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
filesize INTEGER NOT NULL,
|
||||
UNIQUE (folder_id, realname)
|
||||
);`
|
||||
|
||||
var initFoldersTableQuery = `CREATE TABLE IF NOT EXISTS folders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
foldername TEXT NOT NULL
|
||||
);`
|
||||
|
||||
var initFeedbacksTableQuery = `CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
header TEXT NOT NULL
|
||||
);`
|
||||
|
||||
// User table schema definition
|
||||
// role: 0 - Anonymous User, 1 - Admin, 2 - User
|
||||
// postgres avatar references problem
|
||||
var initUsersTableQuery = `CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role INTEGER NOT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
avatar_id INTEGER NOT NULL DEFAULT 0
|
||||
);`
|
||||
|
||||
var initAvatarsTableQuery = `CREATE TABLE IF NOT EXISTS avatars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
avatarname TEXT NOT NULL,
|
||||
avatar BYTEA NOT NULL
|
||||
);`
|
||||
|
||||
var initTagsTableQuery = `CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL REFERENCES users(id)
|
||||
);`
|
||||
|
||||
var initFileHasTagTableQuery = `CREATE TABLE IF NOT EXISTS file_has_tag (
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
PRIMARY KEY (file_id, tag_id)
|
||||
);`
|
||||
|
||||
var initLikesTableQuery = `CREATE TABLE IF NOT EXISTS likes (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
PRIMARY KEY (user_id, file_id)
|
||||
);`
|
||||
|
||||
var initReviewsTableQuery = `CREATE TABLE IF NOT EXISTS reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL
|
||||
);`
|
||||
|
||||
var initPlaybacksTableQuery = `CREATE TABLE IF NOT EXISTS playbacks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
time TIMESTAMP NOT NULL,
|
||||
method INTEGER NOT NULL,
|
||||
duration INTERVAL NOT NULL
|
||||
);`
|
||||
|
||||
var initLogsTableQuery = `CREATE TABLE IF NOT EXISTS logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id)
|
||||
);`
|
||||
|
||||
var initTmpfsTableQuery = `CREATE TABLE IF NOT EXISTS tmpfs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
ffmpeg_config TEXT NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
accessed_time INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
var insertFolderQuery = `INSERT INTO folders (folder, foldername)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id;
|
||||
;`
|
||||
|
||||
var findFolderQuery = `SELECT id FROM folders WHERE folder = $1 LIMIT 1;`
|
||||
|
||||
var findFileQuery = `SELECT id FROM files WHERE folder_id = $1 AND realname = $2 LIMIT 1;`
|
||||
|
||||
var insertFileQuery = `INSERT INTO files (folder_id, realname, filename, filesize)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id;`
|
||||
|
||||
var searchFilesQuery = `SELECT
|
||||
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
|
||||
FROM files
|
||||
JOIN folders ON files.folder_id = folders.id
|
||||
WHERE filename ILIKE $1
|
||||
ORDER BY folders.foldername, files.filename
|
||||
LIMIT $2 OFFSET $3;`
|
||||
|
||||
var getFolderQuery = `SELECT folder FROM folders WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var dropFilesQuery = `DROP TABLE files;`
|
||||
|
||||
var dropFolderQuery = `DROP TABLE folders;`
|
||||
|
||||
var getFileQuery = `SELECT
|
||||
files.id, files.folder_id, files.realname, files.filename, folders.foldername, files.filesize
|
||||
FROM files
|
||||
JOIN folders ON files.folder_id = folders.id
|
||||
WHERE files.id = $1
|
||||
LIMIT 1;`
|
||||
|
||||
var searchFoldersQuery = `SELECT
|
||||
id, folder, foldername
|
||||
FROM folders
|
||||
WHERE foldername ILIKE $1
|
||||
ORDER BY foldername
|
||||
LIMIT $2 OFFSET $3;`
|
||||
|
||||
var getFilesInFolderQuery = `SELECT
|
||||
files.id, files.filename, files.filesize, folders.foldername, folders.folder
|
||||
FROM files
|
||||
JOIN folders ON files.folder_id = folders.id
|
||||
WHERE folder_id = $1
|
||||
ORDER BY files.filename
|
||||
LIMIT $2 OFFSET $3;`
|
||||
|
||||
var getRandomFilesQuery = `SELECT
|
||||
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
|
||||
FROM files
|
||||
JOIN folders ON files.folder_id = folders.id
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $1;`
|
||||
|
||||
var getRandomFilesWithTagQuery = `SELECT
|
||||
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
|
||||
FROM file_has_tag
|
||||
JOIN files ON file_has_tag.file_id = files.id
|
||||
JOIN folders ON files.folder_id = folders.id
|
||||
WHERE file_has_tag.tag_id = $1
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $2;`
|
||||
|
||||
var insertFeedbackQuery = `INSERT INTO feedbacks (time, content, user_id, header)
|
||||
VALUES ($1, $2, $3, $4);`
|
||||
|
||||
var getFeedbacksQuery = `SELECT
|
||||
feedbacks.id, feedbacks.time, feedbacks.content, feedbacks.header,
|
||||
users.id, users.username, users.role, users.active, users.avatar_id
|
||||
FROM feedbacks
|
||||
JOIN users ON feedbacks.user_id = users.id
|
||||
ORDER BY feedbacks.time
|
||||
;`
|
||||
|
||||
var deleteFeedbackQuery = `DELETE FROM feedbacks WHERE id = $1;`
|
||||
|
||||
var insertUserQuery = `INSERT INTO users (username, password, role, active, avatar_id)
|
||||
VALUES ($1, $2, $3, $4, $5);`
|
||||
|
||||
var countUserQuery = `SELECT count(*) FROM users;`
|
||||
|
||||
var countAdminQuery = `SELECT count(*) FROM users WHERE role= 1;`
|
||||
|
||||
var getUserQuery = `SELECT id, username, password, role, active, avatar_id FROM users WHERE username = $1 LIMIT 1;`
|
||||
|
||||
var getUsersQuery = `SELECT id, username, role, active, avatar_id FROM users;`
|
||||
|
||||
var getUserByIdQuery = `SELECT id, username, role, active, avatar_id FROM users WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var updateUserActiveQuery = `UPDATE users SET active = $1 WHERE id = $2;`
|
||||
|
||||
var updateUsernameQuery = `UPDATE users SET username = $1 WHERE id = $2;`
|
||||
|
||||
var updateUserPasswordQuery = `UPDATE users SET password = $1 WHERE id = $2;`
|
||||
|
||||
var getAnonymousUserQuery = `SELECT id, username, role, avatar_id FROM users WHERE role = 0 LIMIT 1;`
|
||||
|
||||
var insertTagQuery = `INSERT INTO tags (name, description, created_by_user_id) VALUES ($1, $2, $3) RETURNING id;`
|
||||
|
||||
var deleteTagQuery = `DELETE FROM tags WHERE id = $1;`
|
||||
|
||||
var getTagQuery = `SELECT
|
||||
tags.id, tags.name, tags.description,
|
||||
users.id, users.username, users.role, users.avatar_id
|
||||
FROM tags
|
||||
JOIN users ON tags.created_by_user_id = users.id
|
||||
WHERE tags.id = $1 LIMIT 1;`
|
||||
|
||||
var getTagsQuery = `SELECT
|
||||
tags.id, tags.name, tags.description,
|
||||
users.id, users.username, users.role, users.avatar_id
|
||||
FROM tags
|
||||
JOIN users ON tags.created_by_user_id = users.id
|
||||
ORDER BY tags.name
|
||||
;`
|
||||
|
||||
var updateTagQuery = `UPDATE tags SET name = $1, description = $2 WHERE id = $3;`
|
||||
|
||||
// postgres INSERT IGNORE
|
||||
var putTagOnFileQuery = `INSERT INTO file_has_tag (tag_id, file_id, user_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;`
|
||||
|
||||
var getTagsOnFileQuery = `SELECT
|
||||
tags.id, tags.name, tags.description, tags.created_by_user_id
|
||||
FROM file_has_tag
|
||||
JOIN tags ON file_has_tag.tag_id = tags.id
|
||||
WHERE file_has_tag.file_id = $1
|
||||
ORDER BY tags.name
|
||||
;`
|
||||
|
||||
var deleteTagOnFileQuery = `DELETE FROM file_has_tag WHERE tag_id = $1 AND file_id = $2;`
|
||||
|
||||
var deleteTagReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE tag_id = $1;`
|
||||
|
||||
var updateFoldernameQuery = `UPDATE folders SET foldername = $1 WHERE id = $2;`
|
||||
|
||||
var insertReviewQuery = `INSERT INTO reviews (user_id, file_id, created_at, content)
|
||||
VALUES ($1, $2, $3, $4);`
|
||||
|
||||
var getReviewsOnFileQuery = `SELECT
|
||||
reviews.id, reviews.created_at, reviews.updated_at, reviews.content,
|
||||
users.id, users.username, users.role, users.avatar_id,
|
||||
files.id, files.filename
|
||||
FROM reviews
|
||||
JOIN users ON reviews.user_id = users.id
|
||||
JOIN files ON reviews.file_id = files.id
|
||||
WHERE reviews.file_id = $1
|
||||
ORDER BY reviews.created_at
|
||||
;`
|
||||
|
||||
var getReviewQuery = `SELECT id, file_id, user_id, created_at, updated_at, content FROM reviews WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var updateReviewQuery = `UPDATE reviews SET content = $1, updated_at = $2 WHERE id = $3;`
|
||||
|
||||
var deleteReviewQuery = `DELETE FROM reviews WHERE id = $1;`
|
||||
|
||||
var getReviewsByUserQuery = `SELECT
|
||||
reviews.id, reviews.created_at, reviews.updated_at, reviews.content,
|
||||
users.id, users.username, users.role, users.avatar_id,
|
||||
files.id, files.filename
|
||||
FROM reviews
|
||||
JOIN users ON reviews.user_id = users.id
|
||||
JOIN files ON reviews.file_id = files.id
|
||||
WHERE reviews.user_id = $1
|
||||
ORDER BY reviews.created_at
|
||||
;`
|
||||
|
||||
var deleteFileQuery = `DELETE FROM files WHERE id = $1;`
|
||||
|
||||
var deleteFileReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE file_id = $1;`
|
||||
|
||||
var deleteFileReferenceInReviewsQuery = `DELETE FROM reviews WHERE file_id = $1;`
|
||||
|
||||
var updateFilenameQuery = `UPDATE files SET filename = $1 WHERE id = $2;`
|
||||
|
||||
var resetFilenameQuery = `UPDATE files SET filename = realname WHERE id = $1;`
|
||||
|
||||
var recordPlaybackQuery = `INSERT INTO playbacks (user_id, file_id, time, method, duration) VALUES ($1, $2, $3, $4, $5);`
|
||||
|
||||
type Stmt struct {
|
||||
initFilesTable *sql.Stmt
|
||||
initFoldersTable *sql.Stmt
|
||||
initFeedbacksTable *sql.Stmt
|
||||
initUsersTable *sql.Stmt
|
||||
initAvatarsTable *sql.Stmt
|
||||
initTagsTable *sql.Stmt
|
||||
initFileHasTag *sql.Stmt
|
||||
initLikesTable *sql.Stmt
|
||||
initReviewsTable *sql.Stmt
|
||||
initPlaybacksTable *sql.Stmt
|
||||
initLogsTable *sql.Stmt
|
||||
initTmpfsTable *sql.Stmt
|
||||
insertFolder *sql.Stmt
|
||||
insertFile *sql.Stmt
|
||||
findFolder *sql.Stmt
|
||||
findFile *sql.Stmt
|
||||
searchFiles *sql.Stmt
|
||||
getFolder *sql.Stmt
|
||||
dropFiles *sql.Stmt
|
||||
dropFolder *sql.Stmt
|
||||
getFile *sql.Stmt
|
||||
searchFolders *sql.Stmt
|
||||
getFilesInFolder *sql.Stmt
|
||||
getRandomFiles *sql.Stmt
|
||||
getRandomFilesWithTag *sql.Stmt
|
||||
insertFeedback *sql.Stmt
|
||||
getFeedbacks *sql.Stmt
|
||||
deleteFeedback *sql.Stmt
|
||||
insertUser *sql.Stmt
|
||||
countUser *sql.Stmt
|
||||
countAdmin *sql.Stmt
|
||||
getUser *sql.Stmt
|
||||
getUsers *sql.Stmt
|
||||
getUserById *sql.Stmt
|
||||
updateUserActive *sql.Stmt
|
||||
updateUsername *sql.Stmt
|
||||
updateUserPassword *sql.Stmt
|
||||
getAnonymousUser *sql.Stmt
|
||||
insertTag *sql.Stmt
|
||||
deleteTag *sql.Stmt
|
||||
getTag *sql.Stmt
|
||||
getTags *sql.Stmt
|
||||
updateTag *sql.Stmt
|
||||
putTagOnFile *sql.Stmt
|
||||
getTagsOnFile *sql.Stmt
|
||||
deleteTagOnFile *sql.Stmt
|
||||
deleteTagReferenceInFileHasTag *sql.Stmt
|
||||
updateFoldername *sql.Stmt
|
||||
insertReview *sql.Stmt
|
||||
getReviewsOnFile *sql.Stmt
|
||||
getReview *sql.Stmt
|
||||
updateReview *sql.Stmt
|
||||
deleteReview *sql.Stmt
|
||||
getReviewsByUser *sql.Stmt
|
||||
deleteFile *sql.Stmt
|
||||
deleteFileReferenceInFileHasTag *sql.Stmt
|
||||
deleteFileReferenceInReviews *sql.Stmt
|
||||
updateFilename *sql.Stmt
|
||||
resetFilename *sql.Stmt
|
||||
recordPlaybackStmt *sql.Stmt
|
||||
}
|
||||
|
||||
func NewPreparedStatement(sqlConn *sql.DB) (*Stmt, error) {
|
||||
var err error
|
||||
|
||||
stmt := &Stmt{}
|
||||
|
||||
// init folders table
|
||||
stmt.initFoldersTable, err = sqlConn.Prepare(initFoldersTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFoldersTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init files table
|
||||
stmt.initFilesTable, err = sqlConn.Prepare(initFilesTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFilesTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init avatars table
|
||||
stmt.initAvatarsTable, err = sqlConn.Prepare(initAvatarsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initAvatarsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init users table
|
||||
stmt.initUsersTable, err = sqlConn.Prepare(initUsersTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initUsersTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init feedbacks tables
|
||||
stmt.initFeedbacksTable, err = sqlConn.Prepare(initFeedbacksTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFeedbacksTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init tags table
|
||||
stmt.initTagsTable, err = sqlConn.Prepare(initTagsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initTagsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init file_has_tag table
|
||||
stmt.initFileHasTag, err = sqlConn.Prepare(initFileHasTagTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFileHasTag.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init likes table
|
||||
stmt.initLikesTable, err = sqlConn.Prepare(initLikesTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initLikesTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init reviews table
|
||||
stmt.initReviewsTable, err = sqlConn.Prepare(initReviewsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initReviewsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init playbacks table
|
||||
stmt.initPlaybacksTable, err = sqlConn.Prepare(initPlaybacksTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initPlaybacksTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init logs table
|
||||
stmt.initLogsTable, err = sqlConn.Prepare(initLogsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initLogsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init tmpfs table
|
||||
stmt.initTmpfsTable, err = sqlConn.Prepare(initTmpfsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initTmpfsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("Init tables finished")
|
||||
|
||||
// init insert folder statement
|
||||
stmt.insertFolder, err = sqlConn.Prepare(insertFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init findFolder statement
|
||||
stmt.findFolder, err = sqlConn.Prepare(findFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init findFile statement
|
||||
stmt.findFile, err = sqlConn.Prepare(findFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertFile stmt
|
||||
stmt.insertFile, err = sqlConn.Prepare(insertFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init searchFile stmt
|
||||
stmt.searchFiles, err = sqlConn.Prepare(searchFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFolder stmt
|
||||
stmt.getFolder, err = sqlConn.Prepare(getFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init dropFolder stmt
|
||||
stmt.dropFolder, err = sqlConn.Prepare(dropFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init dropFiles stmt
|
||||
stmt.dropFiles, err = sqlConn.Prepare(dropFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFile stmt
|
||||
stmt.getFile, err = sqlConn.Prepare(getFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init searchFolder stmt
|
||||
stmt.searchFolders, err = sqlConn.Prepare(searchFoldersQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFilesInFolder stmt
|
||||
stmt.getFilesInFolder, err = sqlConn.Prepare(getFilesInFolderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getRandomFiles
|
||||
stmt.getRandomFiles, err = sqlConn.Prepare(getRandomFilesQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getRandomFilesWithTag
|
||||
stmt.getRandomFilesWithTag, err = sqlConn.Prepare(getRandomFilesWithTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertFeedback
|
||||
stmt.insertFeedback, err = sqlConn.Prepare(insertFeedbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getFeedbacks
|
||||
stmt.getFeedbacks, err = sqlConn.Prepare(getFeedbacksQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteFeedback
|
||||
stmt.deleteFeedback, err = sqlConn.Prepare(deleteFeedbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertUser
|
||||
stmt.insertUser, err = sqlConn.Prepare(insertUserQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init countUser
|
||||
stmt.countUser, err = sqlConn.Prepare(countUserQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init countAdmin
|
||||
stmt.countAdmin, err = sqlConn.Prepare(countAdminQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getUser
|
||||
stmt.getUser, err = sqlConn.Prepare(getUserQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getUsers
|
||||
stmt.getUsers, err = sqlConn.Prepare(getUsersQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getUserById
|
||||
stmt.getUserById, err = sqlConn.Prepare(getUserByIdQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateUserActive
|
||||
stmt.updateUserActive, err = sqlConn.Prepare(updateUserActiveQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateUsername
|
||||
stmt.updateUsername, err = sqlConn.Prepare(updateUsernameQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateUserPassword
|
||||
stmt.updateUserPassword, err = sqlConn.Prepare(updateUserPasswordQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getAnonymousUser
|
||||
stmt.getAnonymousUser, err = sqlConn.Prepare(getAnonymousUserQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// insert Anonymous user if users is empty
|
||||
userCount := 0
|
||||
err = stmt.countUser.QueryRow().Scan(&userCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userCount == 0 {
|
||||
_, err = stmt.insertUser.Exec("Anonymous user", "", 0, 1, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// init insertTag
|
||||
stmt.insertTag, err = sqlConn.Prepare(insertTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteTag
|
||||
stmt.deleteTag, err = sqlConn.Prepare(deleteTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getTag
|
||||
stmt.getTag, err = sqlConn.Prepare(getTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getTags
|
||||
stmt.getTags, err = sqlConn.Prepare(getTagsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateTag
|
||||
stmt.updateTag, err = sqlConn.Prepare(updateTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init putTagOnFile
|
||||
stmt.putTagOnFile, err = sqlConn.Prepare(putTagOnFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getTagsOnFile
|
||||
stmt.getTagsOnFile, err = sqlConn.Prepare(getTagsOnFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteTagOnFile
|
||||
stmt.deleteTagOnFile, err = sqlConn.Prepare(deleteTagOnFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteTagReferenceInFileHasTag
|
||||
stmt.deleteTagReferenceInFileHasTag, err = sqlConn.Prepare(
|
||||
deleteTagReferenceInFileHasTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateFoldername
|
||||
stmt.updateFoldername, err = sqlConn.Prepare(updateFoldernameQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init insertReview
|
||||
stmt.insertReview, err = sqlConn.Prepare(insertReviewQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getReviewsOnFile
|
||||
stmt.getReviewsOnFile, err = sqlConn.Prepare(getReviewsOnFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getReview
|
||||
stmt.getReview, err = sqlConn.Prepare(getReviewQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateReview
|
||||
stmt.updateReview, err = sqlConn.Prepare(updateReviewQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteReview
|
||||
stmt.deleteReview, err = sqlConn.Prepare(deleteReviewQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init getReviewsByUser
|
||||
stmt.getReviewsByUser, err = sqlConn.Prepare(getReviewsByUserQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteFile
|
||||
stmt.deleteFile, err = sqlConn.Prepare(deleteFileQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteFileReferenceInFileHasTag
|
||||
stmt.deleteFileReferenceInFileHasTag, err = sqlConn.Prepare(
|
||||
deleteFileReferenceInFileHasTagQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init deleteFileReferenceInReviews
|
||||
stmt.deleteFileReferenceInReviews, err = sqlConn.Prepare(
|
||||
deleteFileReferenceInReviewsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init updateFilename
|
||||
stmt.updateFilename, err = sqlConn.Prepare(updateFilenameQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init resetFilename
|
||||
stmt.resetFilename, err = sqlConn.Prepare(resetFilenameQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stmt.recordPlaybackStmt, err = sqlConn.Prepare(recordPlaybackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("Init statements finished")
|
||||
|
||||
return stmt, err
|
||||
}
|
||||
98
pkg/database/struct.go
Normal file
98
pkg/database/struct.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Db *Database `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Folder_id int64 `json:"folder_id"`
|
||||
Foldername string `json:"foldername"`
|
||||
Realname string `json:"-"`
|
||||
Filename string `json:"filename"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
folderCache *Folder
|
||||
}
|
||||
|
||||
type Folder struct {
|
||||
Db *Database `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Folder string `json:"folder"`
|
||||
Foldername string `json:"foldername"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"-"`
|
||||
Role int64 `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
AvatarId int64 `json:"avatar_id"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedByUserId int64 `json:"created_by_user_id"`
|
||||
CreatedByUser *User `json:"created_by_user"`
|
||||
}
|
||||
|
||||
type Review struct {
|
||||
ID int64 `json:"id"`
|
||||
FileId int64 `json:"file_id"`
|
||||
File *File `json:"file"`
|
||||
UserId int64 `json:"user_id"`
|
||||
User *User `json:"user"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
ID int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
User *User `json:"user"`
|
||||
Content string `json:"content"`
|
||||
Header string `json:"header"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type Playback struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FileID int64 `json:"file_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Method int64 `json:"method"`
|
||||
Duration time.Duration `json:"Duration"`
|
||||
}
|
||||
|
||||
var (
|
||||
RoleAnonymous = int64(0)
|
||||
RoleAdmin = int64(1)
|
||||
RoleUser = int64(2)
|
||||
)
|
||||
|
||||
func (f *File) Path() (string, error) {
|
||||
var err error
|
||||
if f.folderCache == nil {
|
||||
f.folderCache, err = f.Db.GetFolder(f.Folder_id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(f.folderCache.Folder, f.Realname), nil
|
||||
}
|
||||
|
||||
func (f *File) Dir() (string, error) {
|
||||
var err error
|
||||
if f.folderCache == nil {
|
||||
f.folderCache, err = f.Db.GetFolder(f.Folder_id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.folderCache.Folder, nil
|
||||
}
|
||||
91
pkg/tmpfs/tmpfs.go
Normal file
91
pkg/tmpfs/tmpfs.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tmpfs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"msw-open-music/pkg/commonconfig"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Tmpfs struct {
|
||||
record map[string]int64
|
||||
Config commonconfig.TmpfsConfig
|
||||
wg sync.WaitGroup
|
||||
recordLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) GetObjFilePath(id int64, ffmpegConfig commonconfig.FfmpegConfig) string {
|
||||
return filepath.Join(tmpfs.Config.Root, strconv.FormatInt(id, 10)+"."+ffmpegConfig.Name+"."+ffmpegConfig.Format)
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) GetLock(filename string) *sync.Mutex {
|
||||
if _, ok := tmpfs.recordLocks[filename]; !ok {
|
||||
tmpfs.recordLocks[filename] = &sync.Mutex{}
|
||||
}
|
||||
return tmpfs.recordLocks[filename]
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Lock(filename string) {
|
||||
tmpfs.GetLock(filename).Lock()
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Unlock(filename string) {
|
||||
tmpfs.GetLock(filename).Unlock()
|
||||
}
|
||||
|
||||
func NewTmpfs(config commonconfig.TmpfsConfig) *Tmpfs {
|
||||
tmpfs := &Tmpfs{
|
||||
record: make(map[string]int64),
|
||||
Config: config,
|
||||
recordLocks: make(map[string]*sync.Mutex),
|
||||
}
|
||||
// check if the directory exists
|
||||
if _, err := os.Stat(tmpfs.Config.Root); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(tmpfs.Config.Root, 0755)
|
||||
if err != nil {
|
||||
log.Fatalln("[tmpfs] Failed to create directory", tmpfs.Config.Root)
|
||||
}
|
||||
}
|
||||
tmpfs.wg.Add(1)
|
||||
go tmpfs.Cleaner()
|
||||
return tmpfs
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Record(filename string) {
|
||||
tmpfs.record[filename] = time.Now().Unix()
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Exits(filename string) bool {
|
||||
_, ok := tmpfs.record[filename]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (tmpfs *Tmpfs) Cleaner() {
|
||||
var err error
|
||||
for {
|
||||
now := time.Now().Unix()
|
||||
for path, lock := range tmpfs.recordLocks {
|
||||
lock.Lock()
|
||||
recordTime, ok := tmpfs.record[path]
|
||||
if !ok {
|
||||
lock.Unlock()
|
||||
continue
|
||||
}
|
||||
if now-recordTime > tmpfs.Config.FileLifeTime {
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
log.Println("[tmpfs] Failed to remove file", err)
|
||||
}
|
||||
log.Println("[tmpfs] Deleted file", path)
|
||||
delete(tmpfs.record, path)
|
||||
delete(tmpfs.recordLocks, path)
|
||||
}
|
||||
lock.Unlock()
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
4
web/Caddyfile
Normal file
4
web/Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
||||
:8081 {
|
||||
reverse_proxy /api/* localhost:8080
|
||||
reverse_proxy * localhost:3000
|
||||
}
|
||||
9
web/README.md
Normal file
9
web/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# MSW Open Music Web Frontend
|
||||
|
||||
This is a React single page application. And use Preact instead of React to achieve a smaller file size.
|
||||
|
||||
`node_modules` only has 19M. We uses esbuild and shell scripts and build only takes a milliseconds!
|
||||
|
||||
## How to build
|
||||
|
||||
Simple run `./build.sh`, then all output files are under `./build/` directory.
|
||||
3
web/axios.min.js
vendored
3
web/axios.min.js
vendored
File diff suppressed because one or more lines are too long
6
web/build.sh
Executable file
6
web/build.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
rm -rf build
|
||||
cp -raf public build
|
||||
./node_modules/.bin/esbuild src/index.jsx --bundle --outfile=build/msw-open-music.js --alias:react=preact/compat --alias:react-dom=preact/compat --minify --analyze
|
||||
cat public/index.html | sed "s/%PUBLIC_URL%/$PUBLIC_URL/" > build/index.html
|
||||
|
||||
echo "Build done, output files under ./build directory"
|
||||
@@ -1,41 +0,0 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>MSW Open Music Project</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
|
||||
<link rel="stylesheet" href="water.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<!-- Add to homescreen for Chrome on Android -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="favicon.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="base">
|
||||
<header class="header">
|
||||
<h3 class="title">
|
||||
<img class="logo" src="favicon.png" />
|
||||
<span class="title-text">MSW Open Music Project</span>
|
||||
</h3>
|
||||
<nav class="nav">
|
||||
<router-link class="nav-link" to="/">I'm Feeling Lucky</router-link>
|
||||
<router-link class="nav-link" to="/search_files">Files</router-link>
|
||||
<router-link class="nav-link" to="/search_folders">Folders</router-link>
|
||||
<router-link class="nav-link" to="/manage">Manage</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<router-view :token="token" @set_token="set_token" @play_audio="play_audio"></router-view>
|
||||
</main>
|
||||
<footer>
|
||||
<component-audio-player :token="token" @stop="stop" @play_audio="play_audio" :file=playing_audio_file></component-audio-player>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
<script src="vue.js"></script>
|
||||
<script src="vue-router.js"></script>
|
||||
<script src="axios.min.js"></script>
|
||||
<script src="index.js"></script>
|
||||
|
||||
</html>
|
||||
942
web/index.js
942
web/index.js
@@ -1,942 +0,0 @@
|
||||
const component_share = {
|
||||
emits: ['play_audio', 'set_token'],
|
||||
props: ['token'],
|
||||
template: `
|
||||
<div class="page">
|
||||
<h3>Share with others!</h3>
|
||||
<p v-if="error_status">{{ error_status }}</p>
|
||||
<p>Share link: <a :href="computed_share_link">{{ computed_share_link }}</a> , or share this page directly.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Folder Name</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<component-file :file=file @play_audio="$emit('play_audio', $event)"></component-file>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
computed: {
|
||||
computed_share_link() {
|
||||
return window.location.href
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
file: {},
|
||||
error_status: "",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.id) {
|
||||
this.get_file_info()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
get_file_info() {
|
||||
axios.post('/api/v1/get_file_info', {
|
||||
id: parseInt(this.$route.query.id),
|
||||
}).then((response) => {
|
||||
this.file = response.data
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.error_status = error.response.data.status
|
||||
} else {
|
||||
this.error_status = 'Network error'
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_search_folders = {
|
||||
emits: ['play_audio', 'set_token'],
|
||||
props: ['token'],
|
||||
data() {
|
||||
return {
|
||||
search_foldernames: "",
|
||||
folders: [],
|
||||
folder: {},
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
folder_offset: 0,
|
||||
folder_limit: 10,
|
||||
files_in_folder: [],
|
||||
playing_audio_file: {},
|
||||
is_loading: false,
|
||||
error_status: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
computed_folders_page() {
|
||||
if (this.is_loading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
if (this.error_status) {
|
||||
return this.error_status
|
||||
}
|
||||
return this.offset + ' ~ ' + (this.offset + this.folders.length)
|
||||
},
|
||||
computed_files_page() {
|
||||
if (this.is_loading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
if (this.error_status) {
|
||||
return this.error_status
|
||||
}
|
||||
return this.folder_offset + ' ~ ' + (this.folder_offset + this.files_in_folder.length)
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="page">
|
||||
<h3>Search Folders</h3>
|
||||
<div class="search_toolbar">
|
||||
<input type="text" @keyup.enter="first_search_folders" v-model="search_foldernames" placeholder="Enter folder name" />
|
||||
<button @click="first_search_folders">Search Folders</Button>
|
||||
<button @click="last_page">Last Page</button>
|
||||
<button disabled>{{ computed_folders_page }}</button>
|
||||
<button @click="next_page">Next Page</button>
|
||||
</div>
|
||||
|
||||
<table v-if="folders.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Folder Name</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="folder in folders">
|
||||
<td class="clickable" @click="view_folder(folder)">{{ folder.foldername }}</td>
|
||||
<td><button @click="view_folder(folder)">View</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Files in folder</h3>
|
||||
<div class="search_toolbar">
|
||||
<button @click="folder_last_page">Last Page</button>
|
||||
<button disabled>{{ computed_files_page }}</button>
|
||||
<button @click="folder_next_page">Next Page</button>
|
||||
</div>
|
||||
<table v-if="files_in_folder.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Folder Name</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files_in_folder">
|
||||
<component-file :file=file @play_audio="$emit('play_audio', $event)"></component-file>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
mounted() {
|
||||
if (this.$route.query.folder_id) {
|
||||
this.folder.id = parseInt(this.$route.query.folder_id)
|
||||
this.get_files_in_folder()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
folder_last_page() {
|
||||
this.folder_offset = this.folder_offset - this.folder_limit
|
||||
if (this.folder_offset < 0) {
|
||||
this.folder_offset = 0
|
||||
return
|
||||
}
|
||||
this.get_files_in_folder()
|
||||
},
|
||||
folder_next_page() {
|
||||
this.folder_offset = this.folder_offset + this.folder_limit
|
||||
this.get_files_in_folder()
|
||||
},
|
||||
view_folder(folder) {
|
||||
this.folder = folder
|
||||
this.get_files_in_folder()
|
||||
},
|
||||
get_files_in_folder() {
|
||||
this.is_loading = true
|
||||
axios.post('/api/v1/get_files_in_folder', {
|
||||
folder_id: this.folder.id,
|
||||
limit: this.folder_limit,
|
||||
offset: this.folder_offset,
|
||||
}).then((response) => {
|
||||
this.error_status = ""
|
||||
this.files_in_folder = response.data.files
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.error_status = error.response.data.status
|
||||
} else {
|
||||
this.error_status = 'Network error'
|
||||
}
|
||||
}).finally(() => {
|
||||
this.is_loading = false
|
||||
})
|
||||
},
|
||||
last_page() {
|
||||
this.offset = this.offset - this.limit
|
||||
if (this.offset < 0) {
|
||||
this.offset = 0
|
||||
return
|
||||
}
|
||||
this.search_folders()
|
||||
},
|
||||
next_page() {
|
||||
this.offset = this.offset + this.limit
|
||||
this.search_folders()
|
||||
},
|
||||
first_search_folders() {
|
||||
this.offset = 0
|
||||
this.search_folders()
|
||||
},
|
||||
search_folders() {
|
||||
this.is_loading = true
|
||||
axios.post('/api/v1/search_folders', {
|
||||
foldername: this.search_foldernames,
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
}).then((response) => {
|
||||
this.error_status = ""
|
||||
this.folders = response.data.folders
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.error_status = error.response.data.status
|
||||
} else {
|
||||
this.error_status = 'Network error'
|
||||
}
|
||||
}).finally(() => {
|
||||
this.is_loading = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_token = {
|
||||
progs: ['token'],
|
||||
emits: ['set_token'],
|
||||
data() {
|
||||
return {
|
||||
token_tmp: "",
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<table><tbody><tr>
|
||||
<td>Token</td>
|
||||
<td><input type="text" v-model="token_tmp" @change="emit_set_token" placeholder="token" /></td>
|
||||
</tr></tbody></table>
|
||||
`,
|
||||
methods: {
|
||||
emit_set_token() {
|
||||
this.$emit('set_token', this.token_tmp)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_manage= {
|
||||
props: ['token'],
|
||||
emits: ['set_token'],
|
||||
data() {
|
||||
return {
|
||||
feedback: "",
|
||||
feedback_status: "Submit",
|
||||
feedback_placeholder: "feedback...",
|
||||
submit_disabled: false,
|
||||
is_err: false,
|
||||
err_msg: "",
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="page">
|
||||
<div class="description">
|
||||
<h4>关于本站</h4>
|
||||
<p>一只随处可见的 葱厨&车万人 想听 TA 屯在硬盘里的音乐。</p>
|
||||
<p>一点点说明:下方播放器的 Raw 模式即不转码直接播放源文件,支持断点续传;Prepare 模式:勾选后播放的文件将提前在服务器端转码,然后以支持断点续传的方式提供,如果你的网络不稳定,经常播放到一半就中断,可以尝试勾选 Prepare。</p>
|
||||
<p>站内音乐来自公开网络,仅供个人使用,如有侵权或建议请提交反馈</p>
|
||||
<div class="feedback">
|
||||
<input type="text" v-model="feedback" :disabled="submit_disabled" :placeholder="feedback_placeholder"/>
|
||||
<button @click="submit_feedback" :disabled="submit_disabled">{{ feedback_status }}</button>
|
||||
<label v-if="is_err">{{ err_msg }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<component-token :token="token" @set_token="$emit('set_token', $event)"></component-token>
|
||||
<component-manage-database :token="token"></component-manage-database>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
submit_feedback() {
|
||||
axios.post('/api/v1/feedback', {
|
||||
feedback: this.feedback,
|
||||
}).then((response) => {
|
||||
this.submit_disabled = true
|
||||
this.feedback = ""
|
||||
this.feedback_status = "Success"
|
||||
this.feedback_placeholder = "Thanks for your feedback!"
|
||||
this.is_err = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.is_err = true
|
||||
this.err_msg = err.response.data.status
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
const component_manage_database = {
|
||||
props: ['token'],
|
||||
data() {
|
||||
return {
|
||||
root: "",
|
||||
pattern: [".flac", ".mp3"],
|
||||
pattern_tmp: "",
|
||||
s: "",
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Root</td>
|
||||
<td><input type="text" v-model="root" placeholder="/path/to/root" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><button @click="add_pattern">Add Pattern</button></td>
|
||||
<td><input type="text" v-model="pattern_tmp" placeholder=".wav" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><strong>Pattern List</strong></td>
|
||||
</tr>
|
||||
<tr v-for="p in pattern">
|
||||
<td colspan="2">{{ p }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><button @click="update_database">Update</button></td>
|
||||
<td><button @click="reset_database">Reset</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>{{ s }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
add_pattern() {
|
||||
this.pattern.push(this.pattern_tmp)
|
||||
this.pattern_tmp = ""
|
||||
},
|
||||
reset_database() {
|
||||
axios.post('/api/v1/reset', {
|
||||
token: this.token,
|
||||
}).then((response) => {
|
||||
this.s = response.data.status
|
||||
}).catch((err) => {
|
||||
this.s = err.response.data.status
|
||||
})
|
||||
},
|
||||
update_database() {
|
||||
this.s = "Updating..."
|
||||
axios.post('/api/v1/walk', {
|
||||
token: this.token,
|
||||
root: this.root,
|
||||
pattern: this.pattern,
|
||||
}).then((response) => {
|
||||
this.s = response.data.status
|
||||
}).catch((err) => {
|
||||
this.s = err.response.data.status
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const component_file_dialog = {
|
||||
props: ['file', 'show_dialog'],
|
||||
emits: ['play_audio', 'close_dialog'],
|
||||
template: `
|
||||
<dialog open v-if="show_dialog">
|
||||
<p>{{ file.filename }}</p>
|
||||
<p>
|
||||
Download 使用 Axios 异步下载<br />
|
||||
Play 调用网页播放器播放<br />
|
||||
</p>
|
||||
<button @click="download_file(file)" :disabled="disabled">{{ computed_download_status }}</button>
|
||||
<button @click="emit_play_audio">Play</button>
|
||||
<button @click="share">Share</button>
|
||||
<button @click="emit_close_dialog">Close</button>
|
||||
</dialog>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
download_loaded: 0,
|
||||
disabled: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
share() {
|
||||
this.$router.push({
|
||||
path: '/share',
|
||||
query: {
|
||||
id: this.file.id,
|
||||
},
|
||||
})
|
||||
this.emit_close_dialog()
|
||||
},
|
||||
emit_close_dialog() {
|
||||
this.$emit('close_dialog')
|
||||
},
|
||||
emit_play_audio() {
|
||||
console.log("pressed button")
|
||||
this.$emit("play_audio", this.file)
|
||||
this.emit_close_dialog()
|
||||
},
|
||||
download_file(file) {
|
||||
this.disabled = true
|
||||
axios({
|
||||
url: '/api/v1/get_file',
|
||||
method: 'POST',
|
||||
responseType: 'blob', // important
|
||||
data: {
|
||||
id: file.id,
|
||||
},
|
||||
onDownloadProgress: ProgressEvent => {
|
||||
this.download_loaded = ProgressEvent.loaded
|
||||
}
|
||||
}).then((response) => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', file.filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
this.download_loaded = 0
|
||||
this.disabled = false
|
||||
this.emit_close_dialog()
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computed_download_status() {
|
||||
if (this.download_loaded === 0) {
|
||||
return 'Download'
|
||||
} else {
|
||||
return Math.round(this.download_loaded / this.file.filesize * 100) + '%'
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_file = {
|
||||
props: ['file'],
|
||||
emits: ['play_audio'],
|
||||
template: `
|
||||
<td class="clickable" @click="click_filename">{{ file.filename }}</td>
|
||||
<td class="clickable" @click="show_folder">{{ file.foldername }}</td>
|
||||
<td>{{ computed_readable_size }}
|
||||
<component-file-dialog
|
||||
@close_dialog="close_dialog"
|
||||
@play_audio="$emit('play_audio', $event)"
|
||||
:show_dialog="show_dialog"
|
||||
:file="file"
|
||||
></component-file-dialog>
|
||||
</td>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
download_loaded: 0,
|
||||
disabled: false,
|
||||
show_dialog: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
click_filename() {
|
||||
if (this.show_dialog) {
|
||||
this.file.play_back_type = 'stream'
|
||||
this.$emit('play_audio', this.file)
|
||||
this.show_dialog = false
|
||||
} else {
|
||||
this.show_dialog = true
|
||||
}
|
||||
},
|
||||
show_folder() {
|
||||
this.$router.push({
|
||||
path: '/search_folders',
|
||||
query: {
|
||||
folder_id: this.file.folder_id,
|
||||
},
|
||||
})
|
||||
},
|
||||
close_dialog() {
|
||||
this.show_dialog = false
|
||||
},
|
||||
dialog() {
|
||||
this.show_dialog = this.show_dialog ? false : true
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computed_readable_size() {
|
||||
let filesize = this.file.filesize
|
||||
if (filesize < 1024) {
|
||||
return filesize
|
||||
}
|
||||
if (filesize < 1024 * 1024) {
|
||||
return Math.round(filesize / 1024) + 'K'
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024) {
|
||||
return Math.round(filesize / 1024 / 1024) + 'M'
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024 * 1024) {
|
||||
return Math.round(filesize / 1024 / 1024 / 1024) + 'G'
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_audio_player = {
|
||||
emits: ['stop', 'play_audio'],
|
||||
data() {
|
||||
return {
|
||||
loop: true,
|
||||
ffmpeg_config: {},
|
||||
show_dialog: false,
|
||||
is_preparing: false,
|
||||
prepare: false,
|
||||
raw: false,
|
||||
playing_url: "",
|
||||
prepared_filesize: 0,
|
||||
playing_file: {},
|
||||
error_status: "",
|
||||
}
|
||||
},
|
||||
props: ['file', 'token'],
|
||||
template: `
|
||||
<div>
|
||||
<h5>Player Status</h5>
|
||||
<component-file-dialog
|
||||
@close_dialog="close_dialog"
|
||||
@play_audio="$emit('play_audio', $event)"
|
||||
:show_dialog="show_dialog"
|
||||
:file="file"
|
||||
></component-file-dialog>
|
||||
<span v-if="computed_show">
|
||||
<button @click="dialog">{{ file.filename }}</button>
|
||||
<button @click="show_folder">{{ file.foldername }}</button>
|
||||
<button disabled>{{ computed_readable_size }}</button>
|
||||
<button v-if="error_status" @click="retry">Retry</button>
|
||||
<button @click="emit_stop">Stop</button>
|
||||
</span>
|
||||
<br />
|
||||
<input type="checkbox" v-model="loop" />
|
||||
<label>Loop</label>
|
||||
<input type="checkbox" v-model="raw" />
|
||||
<label>Raw</label>
|
||||
<input v-show="!raw" type="checkbox" v-model="prepare" />
|
||||
<label v-show="!raw">Prepare</label><br />
|
||||
<video v-if="computed_video_show" class="audio-player" :src="playing_url" controls autoplay :loop="loop">
|
||||
</video>
|
||||
<component-stream-config @set_ffmpeg_config="set_ffmpeg_config"></component-stream-config>
|
||||
<p>{{ token }}</p>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
emit_stop() {
|
||||
this.$emit('stop')
|
||||
},
|
||||
dialog() {
|
||||
this.show_dialog = this.show_dialog ? false : true
|
||||
},
|
||||
close_dialog() {
|
||||
this.show_dialog = false
|
||||
},
|
||||
show_folder() {
|
||||
this.$router.push({
|
||||
path: '/search_folders',
|
||||
query: {
|
||||
folder_id: this.file.folder_id,
|
||||
}
|
||||
})
|
||||
},
|
||||
set_ffmpeg_config(ffmpeg_config) {
|
||||
this.ffmpeg_config = ffmpeg_config
|
||||
},
|
||||
prepare_func() {
|
||||
if (!this.file.id) {
|
||||
return
|
||||
}
|
||||
this.playing_file = {}
|
||||
this.is_preparing = true
|
||||
axios.post('/api/v1/prepare_file_stream_direct', {
|
||||
id: this.file.id,
|
||||
config_name: this.ffmpeg_config.name,
|
||||
}).then(response => {
|
||||
console.log(response.data)
|
||||
this.error_status = ''
|
||||
this.prepared_filesize = response.data.filesize
|
||||
var file = this.file
|
||||
this.playing_file = file
|
||||
this.set_playing_url()
|
||||
console.log('axios done', this.playing_file)
|
||||
}).catch((err) => {
|
||||
if (err.response) {
|
||||
this.error_status = err.response.data.status
|
||||
} else {
|
||||
this.error_status = "Network error"
|
||||
}
|
||||
}).finally(() => {
|
||||
this.is_preparing = false
|
||||
})
|
||||
},
|
||||
set_playing_url() {
|
||||
if (this.raw) {
|
||||
console.log('computed raw rul')
|
||||
this.playing_url = '/api/v1/get_file_direct?id=' + this.playing_file.id
|
||||
} else {
|
||||
if (this.prepare) {
|
||||
console.log('empty playing_file, start prepare')
|
||||
this.playing_url = '/api/v1/get_file_stream_direct?id=' + this.playing_file.id + '&config=' + this.ffmpeg_config.name
|
||||
} else {
|
||||
console.log('computed stream url')
|
||||
this.playing_url = '/api/v1/get_file_stream?id=' + this.playing_file.id + '&config=' + this.ffmpeg_config.name
|
||||
}
|
||||
}
|
||||
},
|
||||
setup_player() {
|
||||
// 如果没有勾选 prepare 则直接播放
|
||||
// 否则进入 prepare 流程
|
||||
this.playing_file = {}
|
||||
if (this.prepare && !this.raw) {
|
||||
this.prepare_func()
|
||||
} else {
|
||||
this.playing_file = this.file
|
||||
this.set_playing_url()
|
||||
}
|
||||
},
|
||||
retry() {
|
||||
this.setup_player()
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
file() {
|
||||
this.setup_player()
|
||||
},
|
||||
raw() {
|
||||
if (this.prepare) {
|
||||
this.prepare_func()
|
||||
} else {
|
||||
this.set_playing_url()
|
||||
}
|
||||
},
|
||||
prepare() {
|
||||
this.playing_file = {}
|
||||
this.prepare_func()
|
||||
},
|
||||
ffmpeg_config() {
|
||||
this.setup_player()
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computed_can_retry() {
|
||||
return this.error_status ? true : false
|
||||
},
|
||||
computed_readable_size() {
|
||||
if (this.is_preparing) {
|
||||
return 'Preparing...'
|
||||
}
|
||||
if (this.error_status) {
|
||||
return this.error_status
|
||||
}
|
||||
let filesize = this.playing_file.filesize
|
||||
if (this.prepare) {
|
||||
filesize = this.prepared_filesize
|
||||
}
|
||||
if (this.raw) {
|
||||
filesize = this.playing_file.filesize
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024) {
|
||||
filesize = Math.round(filesize / 1024) + 'K'
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024 * 1024) {
|
||||
filesize = Math.round(filesize / 1024 / 1024) + 'M'
|
||||
}
|
||||
// add separater to number
|
||||
return filesize.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
},
|
||||
computed_video_show() {
|
||||
if (this.playing_file.id && this.playing_url) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
computed_show() {
|
||||
return this.file.id ? true : false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_search_files = {
|
||||
emits: ['play_audio'],
|
||||
props: ['token'],
|
||||
computed: {
|
||||
computed_files_page() {
|
||||
if (this.is_loading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
if (this.error_status) {
|
||||
return this.error_status
|
||||
}
|
||||
return this.offset + ' ~ ' + (this.offset + this.files.length)
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="page">
|
||||
<h3>Search Files</h3>
|
||||
<div class="search_toolbar">
|
||||
<input type="text" name="filename" @keyup.enter="first_search_files" v-model="search_filenames" placeholder="Enter filename" />
|
||||
<button @click="first_search_files">Search</button>
|
||||
<button @click="last_page">Last Page</button>
|
||||
<button disabled>{{ computed_files_page }}</button>
|
||||
<button @click="next_page">Next Page</button>
|
||||
</div>
|
||||
<table v-if="files.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Folder Name</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files">
|
||||
<component-file :file=file @play_audio="$emit('play_audio', $event)"></component-file>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
search_filenames: '',
|
||||
files: [],
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
playing_audio_file: {},
|
||||
is_loading: false,
|
||||
error_status: "",
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
first_search_files() {
|
||||
this.offset = 0
|
||||
this.search_files()
|
||||
},
|
||||
search_files() {
|
||||
this.is_loading = true
|
||||
axios.post('/api/v1/search_files', {
|
||||
filename: this.search_filenames,
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
}).then((response) => {
|
||||
this.error_status = ""
|
||||
this.files = response.data.files
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.error_status = error.response.data.status
|
||||
} else {
|
||||
this.error_status = 'Network error'
|
||||
}
|
||||
}).finally(() => {
|
||||
this.is_loading = false
|
||||
})
|
||||
},
|
||||
last_page() {
|
||||
this.offset = this.offset - this.limit
|
||||
if (this.offset < 0) {
|
||||
this.offset = 0
|
||||
return
|
||||
}
|
||||
this.search_files()
|
||||
},
|
||||
next_page() {
|
||||
this.offset = this.offset + this.limit
|
||||
this.search_files()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const component_get_random_files = {
|
||||
emits: ['play_audio', 'set_token'],
|
||||
props: ['token'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
is_loading: false,
|
||||
error_status: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
computed_refresh() {
|
||||
if (this.error_status) {
|
||||
return this.error_status
|
||||
}
|
||||
if (this.is_loading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
return 'Refresh'
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="page">
|
||||
<div class="search_toolbar">
|
||||
<button class="refresh" @click="get_random_files">{{ computed_refresh }}</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Folder Name</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files">
|
||||
<component-file :file=file @play_audio="$emit('play_audio', $event)"></component-file>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
mounted() {
|
||||
this.get_random_files()
|
||||
},
|
||||
methods: {
|
||||
get_random_files() {
|
||||
this.is_loading = true
|
||||
axios.get('/api/v1/get_random_files'
|
||||
).then(response => {
|
||||
this.error_status = ""
|
||||
this.files = response.data.files;
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.error_status = error.response.data.status
|
||||
} else {
|
||||
this.error_status = 'Network error'
|
||||
}
|
||||
}).finally(() => {
|
||||
this.is_loading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const component_stream_config = {
|
||||
emits: ['set_ffmpeg_config'],
|
||||
data() {
|
||||
return {
|
||||
ffmpeg_config_list: [],
|
||||
selected_ffmpeg_config: {},
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<select v-model="selected_ffmpeg_config">
|
||||
<option v-for="ffmpeg_config in ffmpeg_config_list" :value="ffmpeg_config">
|
||||
{{ ffmpeg_config.name }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ selected_ffmpeg_config.args }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
mounted() {
|
||||
axios.get('/api/v1/get_ffmpeg_config_list',
|
||||
).then(response => {
|
||||
// 后端返回数据 ffmpeg_configs 是一个字典,name 作为 key,ffmpeg_config{} 作为 value
|
||||
// 为方便前端,此处将 ffmpeg_configs 转为数组,并添加 name 到每个对象中
|
||||
var ffmpeg_configs = response.data.ffmpeg_configs
|
||||
var tmp_list = []
|
||||
for (var key in ffmpeg_configs) {
|
||||
var ffmpeg_config = ffmpeg_configs[key]
|
||||
ffmpeg_config.name = key
|
||||
tmp_list.push(ffmpeg_config)
|
||||
}
|
||||
tmp_list.sort()
|
||||
this.ffmpeg_config_list = tmp_list
|
||||
this.selected_ffmpeg_config = this.ffmpeg_config_list[0]
|
||||
}).catch(err => {
|
||||
this.ffmpeg_config_list = [{name: 'No avaliable config'}]
|
||||
this.selected_ffmpeg_config = this.ffmpeg_config_list[0]
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
selected_ffmpeg_config(n, o) {
|
||||
this.$emit('set_ffmpeg_config', this.selected_ffmpeg_config)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: component_get_random_files},
|
||||
{ path: '/search_files', component: component_search_files},
|
||||
{ path: '/search_folders', component: component_search_folders},
|
||||
{ path: '/manage', component: component_manage},
|
||||
{ path: '/share', component: component_share},
|
||||
]
|
||||
const router = VueRouter.createRouter({
|
||||
history: VueRouter.createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
playing_audio_file: {},
|
||||
token: "default token",
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
stop() {
|
||||
this.playing_audio_file = {}
|
||||
},
|
||||
set_token(token) {
|
||||
this.token = token
|
||||
},
|
||||
play_audio(file) {
|
||||
console.log(file)
|
||||
this.playing_audio_file = file
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app.component('component-search-folders', component_search_folders)
|
||||
app.component('component-manage', component_manage)
|
||||
app.component('component-file', component_file)
|
||||
app.component('component-audio-player', component_audio_player)
|
||||
app.component('component-search-files', component_search_files)
|
||||
app.component('component-get-random-files', component_get_random_files)
|
||||
app.component('component-file-dialog', component_file_dialog)
|
||||
app.component('component-token', component_token)
|
||||
app.component('component-stream-config', component_stream_config)
|
||||
app.component('component-manage-database', component_manage_database)
|
||||
app.component('component-share', component_share)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
738
web/package-lock.json
generated
Normal file
738
web/package-lock.json
generated
Normal file
@@ -0,0 +1,738 @@
|
||||
{
|
||||
"name": "msw-open-music-react",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "msw-open-music-react",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@preact/compat": "^17.1.2",
|
||||
"esbuild": "^0.15.17",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"water.css": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.15.17.tgz",
|
||||
"integrity": "sha512-ay6Ken4u+JStjYmqIgh71jMT0bs/rXpCCDKaMfl78B20QYWJglT5P6Ejfm4hWf6Zi+uUWNe7ZmqakRs2BQYIeg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.15.17.tgz",
|
||||
"integrity": "sha512-IA1O7f7qxw2DX8oqTpugHElr926phs7Rq8ULXleBMk4go5K05BU0mI8BfCkWcYAvcmVaMc13bv5W3LIUlU6Y9w==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@preact/compat": {
|
||||
"version": "17.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@preact/compat/-/compat-17.1.2.tgz",
|
||||
"integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==",
|
||||
"peerDependencies": {
|
||||
"preact": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.0.4.tgz",
|
||||
"integrity": "sha512-gTL8H5USTAKOyVA4xczzDJnC3HMssdFa3tRlwBicXynx9XfiXwneHnYQogwSKpdCkjXISrEKSTtX62rLpNEVQg==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.15.17.tgz",
|
||||
"integrity": "sha512-8MbkDX+kh0kaeYGd6klMbn1uTOXHoDw7UYMd1dQYA5cqBZivf5+pzfaXZSL1RNamJfXW/uWC5+9wX5ejDgpSqg==",
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/android-arm": "0.15.17",
|
||||
"@esbuild/linux-loong64": "0.15.17",
|
||||
"esbuild-android-64": "0.15.17",
|
||||
"esbuild-android-arm64": "0.15.17",
|
||||
"esbuild-darwin-64": "0.15.17",
|
||||
"esbuild-darwin-arm64": "0.15.17",
|
||||
"esbuild-freebsd-64": "0.15.17",
|
||||
"esbuild-freebsd-arm64": "0.15.17",
|
||||
"esbuild-linux-32": "0.15.17",
|
||||
"esbuild-linux-64": "0.15.17",
|
||||
"esbuild-linux-arm": "0.15.17",
|
||||
"esbuild-linux-arm64": "0.15.17",
|
||||
"esbuild-linux-mips64le": "0.15.17",
|
||||
"esbuild-linux-ppc64le": "0.15.17",
|
||||
"esbuild-linux-riscv64": "0.15.17",
|
||||
"esbuild-linux-s390x": "0.15.17",
|
||||
"esbuild-netbsd-64": "0.15.17",
|
||||
"esbuild-openbsd-64": "0.15.17",
|
||||
"esbuild-sunos-64": "0.15.17",
|
||||
"esbuild-windows-32": "0.15.17",
|
||||
"esbuild-windows-64": "0.15.17",
|
||||
"esbuild-windows-arm64": "0.15.17"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-android-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.15.17.tgz",
|
||||
"integrity": "sha512-sUs6cKMAuAyWnJ/66ezWVr9SMRGFSwoMagxzdhXYggSA12zF7krXSuc1Y9JwxHq56wtv/gFAVo97TFm7RBc1Ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-android-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-RLZuCgIx1rexwxwsXTEW40ZiZzdBI1MBphwDRFyms/iiJGwLxqCH7v75iSJk5s6AF6oa80KC6r/RmzyaX/uJNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-darwin-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.17.tgz",
|
||||
"integrity": "sha512-+6RTCZ0hfAb+RqTNq1uVsBcP441yZOSi6CyV9BIBryGGVg8RM3Bc6L45e5b68jdRloddN92ekS50e4ElI+cHQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-darwin-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-ne4UWUHEKWLgYSE5SLr0/TBcID3k9LPnrzzRXzFLTfD+ygjnW1pMEgdMfmOKIe8jYBUYv8x/YoksriTdQb9r/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-freebsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-6my3DrwLOe1zhR8UzVRKeo9AFM9XkApJBcx0IE+qKaEbKKBxYAiDBtd2ZMtRA2agqIwRP0kuHofTiDEzpfA+ZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-freebsd-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-LQL7+f+bz+xmAu1FcDBB304Wm2CjONUcOeF4f3TqG7wYXMxjjYQZBFv+0OVapNXyYrM2vy9JMDbps+SheuOnHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-32": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.15.17.tgz",
|
||||
"integrity": "sha512-7E9vZXMZhINQ4/KcxBxioJ2ao5gbXJ6Pa4/LEUd102g3gadSalpg0LrityFgw7ao6qmjcNWwdEYrXaDnOzyyYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.15.17.tgz",
|
||||
"integrity": "sha512-TnedHtFQSUVlc0J0D4ZMMalYaQ0Zbt7HSwGy4sav7BlXVqDVc/rchJ/a9dathK51apzLgRyXQMseLf6bkloaSQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-arm": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.17.tgz",
|
||||
"integrity": "sha512-+ugCmBTTDIlh+UuC7E/GvyJqjGTX2pNOA+g3isG78aYcfgswrHjvstTtIfljaU95AS30qrVNLgI5h/8TsRWTrg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-oupYfh0lTHg+F/2ZoTNrioB+KLd6x0Zlhjz2Oa1jhl8wCGkNvwe25RytR2/SGPYpoNVcvCeoayWQRwwRuWGgfQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-mips64le": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.17.tgz",
|
||||
"integrity": "sha512-aUVyHwUXJF1hi9jsAT+At+cBxZh2yGICi/e757N6d/zzOD+eVK3PKQj68tAvIflx6/ZpnuCTKol1GpgGYrzERg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-ppc64le": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.17.tgz",
|
||||
"integrity": "sha512-i7789iFTLfLccHPNADCbaZPx9CuQblsBqv2j4XqIBN1jKIJbpQ8iqCkWoHep4PLqqKLtBLtTWh919GsrFGdeJA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-riscv64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.17.tgz",
|
||||
"integrity": "sha512-fEQ/8tnZ2sDniBlPfTXEdg+0OP1olps96HvYdwl8ywJdAlD7AK761EL3lRbRdfMHNOId2N6+CVca43/Fiu/0AQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-s390x": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.17.tgz",
|
||||
"integrity": "sha512-ZBQekST4gYgTKHAvUJtR1kFFulHTDlRZSE8T0wRQCmQqydNkC1teWxlR31xS6MZevjZGfa7OMVJD24bBhei/2Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-netbsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-onNBFaZVN9GzGJMm3aZJJv74n/Q8FjW20G9OfSDhHjvamqJ5vbd42hNk6igQX4lgBCHTZvvBlWDJAMy+tbJAAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-openbsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-QFxHmvjaRrmTCvH/A3EmzqKUSZHRQ7/pbrJeATsb/Q6qckCeL9e7zg/1A3HiZqDXeBUV3yNeBeV1GJBjY6yVyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-sunos-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.17.tgz",
|
||||
"integrity": "sha512-7dHZA8Kc6U8rBTKojJatXtzHTUKJ3CRYimvOGIQQ1yUDOqGx/zZkCH/HkEi3Zg5SWyDj/57E5e1YJPo4ySSw/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-32": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.15.17.tgz",
|
||||
"integrity": "sha512-yDrNrwQ/0k4N3OZItZ6k6YnBUch8+of06YRYc3hFI8VDm7X1rkNZwhttZNAzF6+TtbnK4cIz7H2/EwdSoaGZ3g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.15.17.tgz",
|
||||
"integrity": "sha512-jPnXvB4zMMToNPpCBdt+OEQiYFVs9wlQ5G8vMoJkrYJBp1aEt070MRpBFa6pfBFrgXquqgUiNAohMcTdy+JVFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-I5QeSsz0X66V8rxVhmw03Wzn8Tz63H3L9GrsA7C5wvBXMk3qahLWuEL+l7SZ2DleKkFeZZMu1dPxOak9f1TZ4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.11.3",
|
||||
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
|
||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.4.4.tgz",
|
||||
"integrity": "sha512-SA6tSrUCRfuLWeYsTJDuriRqfFIsrSvuH7SqAJHegx9ZgxadE119rU8oOX/rG5FYEthpdEaEljdjDlnBxvfr+Q==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.4.4.tgz",
|
||||
"integrity": "sha512-0Axverhw5d+4SBhLqLpzPhNkmv7gahUwlUVIOrRLGJ4/uwt30JVajVJXqv2Qr/LCwyvHhQc7YyK1Do8a9Jj7qA==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.0.4",
|
||||
"react-router": "6.4.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.0.tgz",
|
||||
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/water.css": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/water.css/-/water.css-2.1.1.tgz",
|
||||
"integrity": "sha512-gkO5byC+pZ7ndEV18hs/RmxKoDtEZXx06tZU4ocI3IBdv4xV64tlhjIFbDjurysRnNkiy2oQTr8PakRyzZWPJw=="
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@esbuild/android-arm": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.15.17.tgz",
|
||||
"integrity": "sha512-ay6Ken4u+JStjYmqIgh71jMT0bs/rXpCCDKaMfl78B20QYWJglT5P6Ejfm4hWf6Zi+uUWNe7ZmqakRs2BQYIeg==",
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-loong64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.15.17.tgz",
|
||||
"integrity": "sha512-IA1O7f7qxw2DX8oqTpugHElr926phs7Rq8ULXleBMk4go5K05BU0mI8BfCkWcYAvcmVaMc13bv5W3LIUlU6Y9w==",
|
||||
"optional": true
|
||||
},
|
||||
"@preact/compat": {
|
||||
"version": "17.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@preact/compat/-/compat-17.1.2.tgz",
|
||||
"integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@remix-run/router": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.0.4.tgz",
|
||||
"integrity": "sha512-gTL8H5USTAKOyVA4xczzDJnC3HMssdFa3tRlwBicXynx9XfiXwneHnYQogwSKpdCkjXISrEKSTtX62rLpNEVQg=="
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.15.17.tgz",
|
||||
"integrity": "sha512-8MbkDX+kh0kaeYGd6klMbn1uTOXHoDw7UYMd1dQYA5cqBZivf5+pzfaXZSL1RNamJfXW/uWC5+9wX5ejDgpSqg==",
|
||||
"requires": {
|
||||
"@esbuild/android-arm": "0.15.17",
|
||||
"@esbuild/linux-loong64": "0.15.17",
|
||||
"esbuild-android-64": "0.15.17",
|
||||
"esbuild-android-arm64": "0.15.17",
|
||||
"esbuild-darwin-64": "0.15.17",
|
||||
"esbuild-darwin-arm64": "0.15.17",
|
||||
"esbuild-freebsd-64": "0.15.17",
|
||||
"esbuild-freebsd-arm64": "0.15.17",
|
||||
"esbuild-linux-32": "0.15.17",
|
||||
"esbuild-linux-64": "0.15.17",
|
||||
"esbuild-linux-arm": "0.15.17",
|
||||
"esbuild-linux-arm64": "0.15.17",
|
||||
"esbuild-linux-mips64le": "0.15.17",
|
||||
"esbuild-linux-ppc64le": "0.15.17",
|
||||
"esbuild-linux-riscv64": "0.15.17",
|
||||
"esbuild-linux-s390x": "0.15.17",
|
||||
"esbuild-netbsd-64": "0.15.17",
|
||||
"esbuild-openbsd-64": "0.15.17",
|
||||
"esbuild-sunos-64": "0.15.17",
|
||||
"esbuild-windows-32": "0.15.17",
|
||||
"esbuild-windows-64": "0.15.17",
|
||||
"esbuild-windows-arm64": "0.15.17"
|
||||
}
|
||||
},
|
||||
"esbuild-android-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.15.17.tgz",
|
||||
"integrity": "sha512-sUs6cKMAuAyWnJ/66ezWVr9SMRGFSwoMagxzdhXYggSA12zF7krXSuc1Y9JwxHq56wtv/gFAVo97TFm7RBc1Ig==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-android-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-RLZuCgIx1rexwxwsXTEW40ZiZzdBI1MBphwDRFyms/iiJGwLxqCH7v75iSJk5s6AF6oa80KC6r/RmzyaX/uJNg==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.17.tgz",
|
||||
"integrity": "sha512-+6RTCZ0hfAb+RqTNq1uVsBcP441yZOSi6CyV9BIBryGGVg8RM3Bc6L45e5b68jdRloddN92ekS50e4ElI+cHQA==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-ne4UWUHEKWLgYSE5SLr0/TBcID3k9LPnrzzRXzFLTfD+ygjnW1pMEgdMfmOKIe8jYBUYv8x/YoksriTdQb9r/Q==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-6my3DrwLOe1zhR8UzVRKeo9AFM9XkApJBcx0IE+qKaEbKKBxYAiDBtd2ZMtRA2agqIwRP0kuHofTiDEzpfA+ZA==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-LQL7+f+bz+xmAu1FcDBB304Wm2CjONUcOeF4f3TqG7wYXMxjjYQZBFv+0OVapNXyYrM2vy9JMDbps+SheuOnHg==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-32": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.15.17.tgz",
|
||||
"integrity": "sha512-7E9vZXMZhINQ4/KcxBxioJ2ao5gbXJ6Pa4/LEUd102g3gadSalpg0LrityFgw7ao6qmjcNWwdEYrXaDnOzyyYA==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.15.17.tgz",
|
||||
"integrity": "sha512-TnedHtFQSUVlc0J0D4ZMMalYaQ0Zbt7HSwGy4sav7BlXVqDVc/rchJ/a9dathK51apzLgRyXQMseLf6bkloaSQ==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.17.tgz",
|
||||
"integrity": "sha512-+ugCmBTTDIlh+UuC7E/GvyJqjGTX2pNOA+g3isG78aYcfgswrHjvstTtIfljaU95AS30qrVNLgI5h/8TsRWTrg==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-oupYfh0lTHg+F/2ZoTNrioB+KLd6x0Zlhjz2Oa1jhl8wCGkNvwe25RytR2/SGPYpoNVcvCeoayWQRwwRuWGgfQ==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-mips64le": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.17.tgz",
|
||||
"integrity": "sha512-aUVyHwUXJF1hi9jsAT+At+cBxZh2yGICi/e757N6d/zzOD+eVK3PKQj68tAvIflx6/ZpnuCTKol1GpgGYrzERg==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-ppc64le": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.17.tgz",
|
||||
"integrity": "sha512-i7789iFTLfLccHPNADCbaZPx9CuQblsBqv2j4XqIBN1jKIJbpQ8iqCkWoHep4PLqqKLtBLtTWh919GsrFGdeJA==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-riscv64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.17.tgz",
|
||||
"integrity": "sha512-fEQ/8tnZ2sDniBlPfTXEdg+0OP1olps96HvYdwl8ywJdAlD7AK761EL3lRbRdfMHNOId2N6+CVca43/Fiu/0AQ==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-s390x": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.17.tgz",
|
||||
"integrity": "sha512-ZBQekST4gYgTKHAvUJtR1kFFulHTDlRZSE8T0wRQCmQqydNkC1teWxlR31xS6MZevjZGfa7OMVJD24bBhei/2Q==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-netbsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-onNBFaZVN9GzGJMm3aZJJv74n/Q8FjW20G9OfSDhHjvamqJ5vbd42hNk6igQX4lgBCHTZvvBlWDJAMy+tbJAAw==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-openbsd-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.17.tgz",
|
||||
"integrity": "sha512-QFxHmvjaRrmTCvH/A3EmzqKUSZHRQ7/pbrJeATsb/Q6qckCeL9e7zg/1A3HiZqDXeBUV3yNeBeV1GJBjY6yVyA==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-sunos-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.17.tgz",
|
||||
"integrity": "sha512-7dHZA8Kc6U8rBTKojJatXtzHTUKJ3CRYimvOGIQQ1yUDOqGx/zZkCH/HkEi3Zg5SWyDj/57E5e1YJPo4ySSw/w==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-32": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.15.17.tgz",
|
||||
"integrity": "sha512-yDrNrwQ/0k4N3OZItZ6k6YnBUch8+of06YRYc3hFI8VDm7X1rkNZwhttZNAzF6+TtbnK4cIz7H2/EwdSoaGZ3g==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.15.17.tgz",
|
||||
"integrity": "sha512-jPnXvB4zMMToNPpCBdt+OEQiYFVs9wlQ5G8vMoJkrYJBp1aEt070MRpBFa6pfBFrgXquqgUiNAohMcTdy+JVFg==",
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-arm64": {
|
||||
"version": "0.15.17",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.17.tgz",
|
||||
"integrity": "sha512-I5QeSsz0X66V8rxVhmw03Wzn8Tz63H3L9GrsA7C5wvBXMk3qahLWuEL+l7SZ2DleKkFeZZMu1dPxOak9f1TZ4A==",
|
||||
"optional": true
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"peer": true
|
||||
},
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"preact": {
|
||||
"version": "10.11.3",
|
||||
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||
"peer": true
|
||||
},
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
|
||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.0"
|
||||
}
|
||||
},
|
||||
"react-router": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.4.4.tgz",
|
||||
"integrity": "sha512-SA6tSrUCRfuLWeYsTJDuriRqfFIsrSvuH7SqAJHegx9ZgxadE119rU8oOX/rG5FYEthpdEaEljdjDlnBxvfr+Q==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.0.4"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.4.4.tgz",
|
||||
"integrity": "sha512-0Axverhw5d+4SBhLqLpzPhNkmv7gahUwlUVIOrRLGJ4/uwt30JVajVJXqv2Qr/LCwyvHhQc7YyK1Do8a9Jj7qA==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.0.4",
|
||||
"react-router": "6.4.4"
|
||||
}
|
||||
},
|
||||
"scheduler": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.0.tgz",
|
||||
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"water.css": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/water.css/-/water.css-2.1.1.tgz",
|
||||
"integrity": "sha512-gkO5byC+pZ7ndEV18hs/RmxKoDtEZXx06tZU4ocI3IBdv4xV64tlhjIFbDjurysRnNkiy2oQTr8PakRyzZWPJw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
14
web/package.json
Normal file
14
web/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "msw-open-music-react",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@preact/compat": "^17.1.2",
|
||||
"esbuild": "^0.15.17",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"water.css": "^2.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bash ./build.sh"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
19
web/public/index.html
Normal file
19
web/public/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Personal music streaming platform" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/msw-open-music.css" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>MSW Open Music</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="%PUBLIC_URL%/msw-open-music.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
web/public/manifest.json
Normal file
14
web/public/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
web/public/robots.txt
Normal file
3
web/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
3
web/src/.prettierrc.json
Normal file
3
web/src/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tabWidth": 2
|
||||
}
|
||||
121
web/src/App.css
Normal file
121
web/src/App.css
Normal file
@@ -0,0 +1,121 @@
|
||||
html {
|
||||
font-size: 1em;
|
||||
}
|
||||
body {
|
||||
margin: auto;
|
||||
padding-top: 1rem;
|
||||
max-width: unset;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.base {
|
||||
display: grid;
|
||||
grid-row-gap: 1em;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
.header {
|
||||
color: white;
|
||||
background-color: rgb(63, 81, 181);
|
||||
box-shadow: 0 0 8px #393939;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.title {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
}
|
||||
.title-text {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.logo {
|
||||
width: 39px;
|
||||
height: 39px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
.nav-link {
|
||||
color: rgb(229, 232, 245);
|
||||
padding: 1em;
|
||||
}
|
||||
a.unset {
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
}
|
||||
a.active {
|
||||
color: deeppink;
|
||||
background-color: lightgray;
|
||||
border-radius: 0.39em 0.39em 0 0;
|
||||
}
|
||||
.audio-player {
|
||||
height: 39px;
|
||||
width: 100%;
|
||||
}
|
||||
td.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
div.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
div.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
div.search_toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
div.feedback {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button.refresh {
|
||||
width: 100%;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
padding-bottom: 0.5em;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
dialog {
|
||||
border: solid;
|
||||
}
|
||||
.player-options {
|
||||
display: flex;
|
||||
}
|
||||
.ffmpeg-config {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.warp-word {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.number-input {
|
||||
width: 5em;
|
||||
}
|
||||
177
web/src/App.jsx
Normal file
177
web/src/App.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import { HashRouter as Router, Routes, Route, NavLink } from "react-router-dom";
|
||||
import "./App.css";
|
||||
|
||||
import GetRandomFiles from "./component/GetRandomFiles";
|
||||
import SearchFiles from "./component/SearchFiles";
|
||||
import SearchFolders from "./component/SearchFolders";
|
||||
import FilesInFolder from "./component/FilesInFolder";
|
||||
import Manage from "./component/Manage";
|
||||
import ManageUser from "./component/ManageUser";
|
||||
import FileInfo from "./component/FileInfo";
|
||||
import Share from "./component/Share";
|
||||
import Login from "./component/Login";
|
||||
import Register from "./component/Register";
|
||||
import Tags from "./component/Tags";
|
||||
import EditTag from "./component/EditTag";
|
||||
import EditReview from "./component/EditReview";
|
||||
import AudioPlayer from "./component/AudioPlayer";
|
||||
import UserStatus from "./component/UserStatus";
|
||||
import ReviewPage from "./component/ReviewPage";
|
||||
import UserProfile from "./component/UserProfile";
|
||||
import FeedbackPage from "./component/FeedbackPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
|
||||
|
||||
function App() {
|
||||
const [playingFile, setPlayingFile] = useState({});
|
||||
const [user, setUser] = useState({});
|
||||
const [langCode, setLangCode] = useState("en_US");
|
||||
|
||||
useEffect(() => {
|
||||
if (playingFile.id === undefined) {
|
||||
return;
|
||||
}
|
||||
const html = document.getElementsByTagName("html")[0];
|
||||
const retStyle = html.style;
|
||||
const bodyRetStyle = document.body.style
|
||||
html.style = `
|
||||
backdrop-filter: blur(10px);
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
background-position: center;
|
||||
background-image: url("/api/v1/get_file_avatar?id=${playingFile.id}");
|
||||
`;
|
||||
document.body.style.opacity = 0.88;
|
||||
return () => {
|
||||
html.style = retStyle;
|
||||
document.body.style = bodyRetStyle;
|
||||
};
|
||||
}, [playingFile.id]);
|
||||
|
||||
// select language
|
||||
useEffect(() => {
|
||||
const browserCode = window.navigator.language;
|
||||
for (const key in LANG_OPTIONS) {
|
||||
for (const i in LANG_OPTIONS[key].matches) {
|
||||
const code = LANG_OPTIONS[key].matches[i];
|
||||
if (code === browserCode) {
|
||||
setLangCode(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback to english
|
||||
setLangCode("en-US");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="base">
|
||||
<langCodeContext.Provider value={{ langCode, setLangCode }}>
|
||||
<Router>
|
||||
<header className="header">
|
||||
<h3 className="title">
|
||||
<img src="favicon.png" alt="logo" className="logo" />
|
||||
<span className="title-text">MSW Open Music Project</span>
|
||||
<UserStatus user={user} setUser={setUser} />
|
||||
</h3>
|
||||
<nav className="nav">
|
||||
<NavLink to="/" className="nav-link">
|
||||
{Tr("Feeling luckly")}
|
||||
</NavLink>
|
||||
<NavLink to="/files" className="nav-link">
|
||||
{Tr("Files")}
|
||||
</NavLink>
|
||||
<NavLink to="/folders" className="nav-link">
|
||||
{Tr("Folders")}
|
||||
</NavLink>
|
||||
<NavLink to="/manage" className="nav-link">
|
||||
{Tr("Manage")}
|
||||
</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
index
|
||||
path="/"
|
||||
element={<GetRandomFiles setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/files"
|
||||
element={<SearchFiles setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/folders"
|
||||
element={<SearchFolders setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/folders/:id"
|
||||
element={<FilesInFolder setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<Manage
|
||||
user={user}
|
||||
setUser={setUser}
|
||||
setLangCode={setLangCode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/feedbacks"
|
||||
element={<FeedbackPage user={user} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/login"
|
||||
element={<Login user={user} setUser={setUser} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/register"
|
||||
element={<Register user={user} setUser={setUser} />}
|
||||
/>
|
||||
<Route path="/manage/tags" element={<Tags user={user} />} />
|
||||
<Route
|
||||
path="/manage/tags/:id"
|
||||
element={<EditTag user={user} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/reviews/:id"
|
||||
element={<EditReview user={user} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/users"
|
||||
element={<ManageUser user={user} setUser={setUser} />}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/users/:id"
|
||||
element={<UserProfile user={user} setUser={setUser} />}
|
||||
/>
|
||||
<Route
|
||||
path="/files/:id"
|
||||
element={<FileInfo setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/files/:id/share"
|
||||
element={<Share setPlayingFile={setPlayingFile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/files/:id/review"
|
||||
element={
|
||||
<ReviewPage user={user} setPlayingFile={setPlayingFile} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<AudioPlayer
|
||||
playingFile={playingFile}
|
||||
setPlayingFile={setPlayingFile}
|
||||
/>
|
||||
</Router>
|
||||
</langCodeContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
8
web/src/App.test.js
Normal file
8
web/src/App.test.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
275
web/src/component/AudioPlayer.jsx
Normal file
275
web/src/component/AudioPlayer.jsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, useState} from "react";
|
||||
import {useNavigate} from "react-router";
|
||||
import {CalcReadableFilesizeDetail} from "./Common";
|
||||
import FfmpegConfig from "./FfmpegConfig";
|
||||
import FileDialog from "./FileDialog";
|
||||
import {Tr} from "../translate";
|
||||
|
||||
function AudioPlayer(props) {
|
||||
// props.playingFile
|
||||
// props.setPlayingFile
|
||||
|
||||
const [fileDialogShowStatus, setFileDialogShowStatus] = useState(false);
|
||||
const [loop, setLoop] = useState(true);
|
||||
const [raw, setRaw] = useState(false);
|
||||
const [prepare, setPrepare] = useState(false);
|
||||
const [selectedFfmpegConfig, setSelectedFfmpegConfig] = useState({
|
||||
name: "",
|
||||
args: "",
|
||||
});
|
||||
const [playingURL, setPlayingURL] = useState("");
|
||||
const [isPreparing, setIsPreparing] = useState(false);
|
||||
const [timerCount, setTimerCount] = useState(0);
|
||||
const [timerID, setTimerID] = useState(null);
|
||||
const [beginPlayTime, setBeginPlayTime] = useState(null);
|
||||
const [lastID, setLastID] = useState(null);
|
||||
|
||||
const recordPlaybackHistory = async (file_id, method) => {
|
||||
if (file_id === null) {
|
||||
return
|
||||
}
|
||||
const player = document.getElementById('dom-player')
|
||||
const endPlayTime = new Date()
|
||||
let duration = parseInt((endPlayTime - beginPlayTime) / 1000)
|
||||
const maxDuration = parseInt(player.duration)
|
||||
// treat 85% of duration as finished
|
||||
if (duration / maxDuration >= 0.85) {
|
||||
method = 1
|
||||
}
|
||||
duration = duration < maxDuration ? duration : maxDuration
|
||||
setBeginPlayTime(endPlayTime)
|
||||
await fetch('/api/v1/record_playback', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
playback: {
|
||||
file_id,
|
||||
method,
|
||||
duration,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// init mediaSession API
|
||||
useEffect(() => {
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler("stop", () => {
|
||||
props.setPlayingFile({});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePlayMode = () => {
|
||||
if (props.playingFile.id === undefined) {
|
||||
return
|
||||
}
|
||||
if (raw) {
|
||||
console.log("Play raw file");
|
||||
setPlayingURL("/api/v1/get_file_direct?id=" + props.playingFile.id);
|
||||
} else {
|
||||
if (prepare) {
|
||||
// prepare file
|
||||
setIsPreparing(true);
|
||||
fetch("/api/v1/prepare_file_stream_direct", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
id: props.playingFile.id,
|
||||
config_name: selectedFfmpegConfig.name,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
setIsPreparing(false);
|
||||
return;
|
||||
}
|
||||
props.setPlayingFile(data.file);
|
||||
setIsPreparing(false);
|
||||
setPlayingURL(
|
||||
`/api/v1/get_file_stream_direct?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setPlayingURL(
|
||||
`/api/v1/get_file_stream?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// media session related staff
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
title: props.playingFile.filename,
|
||||
album: props.playingFile.foldername,
|
||||
artwork: [{src: "/favicon.png", type: "image/png"}],
|
||||
});
|
||||
}
|
||||
// no playing file
|
||||
if (props.playingFile.id === undefined) {
|
||||
// 3 music stopped
|
||||
recordPlaybackHistory(lastID, 3)
|
||||
setPlayingURL("");
|
||||
return;
|
||||
}
|
||||
// crrently playing file, record interupt
|
||||
if (playingURL) {
|
||||
// 2 music changed
|
||||
recordPlaybackHistory(lastID, 2)
|
||||
}
|
||||
setLastID(props.playingFile.id)
|
||||
// have playingFile, record begin time
|
||||
setBeginPlayTime(new Date())
|
||||
updatePlayMode()
|
||||
}, [props.playingFile.id]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
updatePlayMode()
|
||||
}, [raw, prepare, selectedFfmpegConfig])
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<footer className="vertical">
|
||||
<h5>{Tr("Player status")}</h5>
|
||||
{props.playingFile.id && (
|
||||
<span>
|
||||
<FileDialog
|
||||
showStatus={fileDialogShowStatus}
|
||||
setShowStatus={setFileDialogShowStatus}
|
||||
file={props.playingFile}
|
||||
setPlayingFile={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setFileDialogShowStatus(!fileDialogShowStatus);
|
||||
}}
|
||||
>
|
||||
{props.playingFile.filename}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/folders/${props.playingFile.folder_id}`)}
|
||||
>
|
||||
{props.playingFile.foldername}
|
||||
</button>
|
||||
|
||||
<button disabled>
|
||||
{CalcReadableFilesizeDetail(props.playingFile.filesize)}
|
||||
</button>
|
||||
|
||||
{isPreparing && <button disabled>{Tr("Preparing...")}</button>}
|
||||
|
||||
{playingURL !== "" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
props.setPlayingFile({});
|
||||
}}
|
||||
>
|
||||
{Tr("Stop")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<br />
|
||||
|
||||
<span className="horizontal">
|
||||
<input
|
||||
className="number-input"
|
||||
disabled={timerID !== null}
|
||||
type="number"
|
||||
value={timerCount}
|
||||
onChange={(e) => {
|
||||
setTimerCount(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (timerID != null) {
|
||||
clearInterval(timerID);
|
||||
setTimerID(null);
|
||||
return;
|
||||
}
|
||||
setTimerID(
|
||||
setTimeout(() => {
|
||||
props.setPlayingFile({});
|
||||
setTimerID(null);
|
||||
}, timerCount * 1000 * 60)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Tr("Stop Timer")}
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span>
|
||||
<input
|
||||
checked={loop}
|
||||
onChange={(event) => setLoop(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label>{Tr("Loop")}</label>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<input
|
||||
checked={raw}
|
||||
onChange={(event) => setRaw(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label>{Tr("Raw")}</label>
|
||||
</span>
|
||||
|
||||
{!raw && (
|
||||
<span>
|
||||
<input
|
||||
checked={prepare}
|
||||
onChange={(event) => setPrepare(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label>{Tr("Prepare")}</label>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<audio
|
||||
id="dom-player"
|
||||
controls
|
||||
autoPlay
|
||||
className="audio-player"
|
||||
src={playingURL}
|
||||
onEnded={async () => {
|
||||
const player = document.getElementById('dom-player')
|
||||
if (loop) {
|
||||
player.play()
|
||||
}
|
||||
// 1 music finished
|
||||
recordPlaybackHistory(props.playingFile.id, 1)
|
||||
}}
|
||||
onPlay={async () => {
|
||||
setBeginPlayTime(new Date());
|
||||
}}
|
||||
></audio>
|
||||
|
||||
<FfmpegConfig
|
||||
selectedFfmpegConfig={selectedFfmpegConfig}
|
||||
setSelectedFfmpegConfig={setSelectedFfmpegConfig}
|
||||
/>
|
||||
</footer >
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioPlayer;
|
||||
72
web/src/component/Common.js
Normal file
72
web/src/component/Common.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export function useQuery() {
|
||||
const { search } = useLocation();
|
||||
return useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
export function CalcReadableFilesize(filesize) {
|
||||
if (filesize < 1024) {
|
||||
return filesize;
|
||||
}
|
||||
if (filesize < 1024 * 1024) {
|
||||
return Math.round(filesize / 1024) + "K";
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024) {
|
||||
return Math.round(filesize / 1024 / 1024) + "M";
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024 * 1024) {
|
||||
return Math.round(filesize / 1024 / 1024 / 1024) + "G";
|
||||
}
|
||||
}
|
||||
|
||||
export function CalcReadableFilesizeDetail(filesize) {
|
||||
if (filesize < 1024 * 1024) {
|
||||
return filesize;
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024) {
|
||||
return numberWithCommas(Math.round(filesize / 1024)) + "K";
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024 * 1024) {
|
||||
return numberWithCommas(Math.round(filesize / 1024 / 1024)) + "M";
|
||||
}
|
||||
if (filesize < 1024 * 1024 * 1024 * 1024 * 1024) {
|
||||
return numberWithCommas(Math.round(filesize / 1024 / 1024 / 1024)) + "G";
|
||||
}
|
||||
}
|
||||
|
||||
function numberWithCommas(x) {
|
||||
x = x.toString();
|
||||
var pattern = /(-?\d+)(\d{3})/;
|
||||
while (pattern.test(x)) x = x.replace(pattern, "$1,$2");
|
||||
return x;
|
||||
}
|
||||
|
||||
// convert unix timestamp to %Y-%m-%d %H:%M:%S
|
||||
export function convertIntToDateTime(timestamp) {
|
||||
var date = new Date(timestamp * 1000);
|
||||
var year = date.getFullYear();
|
||||
var month = date.getMonth() + 1;
|
||||
var day = date.getDate();
|
||||
var hour = date.getHours();
|
||||
var minute = date.getMinutes();
|
||||
var second = date.getSeconds();
|
||||
var time =
|
||||
year +
|
||||
"-" +
|
||||
(month < 10 ? "0" + month : month) +
|
||||
"-" +
|
||||
(day < 10 ? "0" + day : day) +
|
||||
" " +
|
||||
(hour < 10 ? "0" + hour : hour) +
|
||||
":" +
|
||||
(minute < 10 ? "0" + minute : minute) +
|
||||
":" +
|
||||
(second < 10 ? "0" + second : second);
|
||||
return time;
|
||||
}
|
||||
|
||||
export function SayHello() {
|
||||
return "Hello";
|
||||
}
|
||||
115
web/src/component/Database.jsx
Normal file
115
web/src/component/Database.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function Database() {
|
||||
const [walkPath, setWalkPath] = useState("");
|
||||
const [patternString, setPatternString] = useState(
|
||||
"wav flac mp3 ogg m4a mka webm"
|
||||
);
|
||||
const [tags, setTags] = useState([]);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function getTags() {
|
||||
fetch("/api/v1/get_tags")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setTags(data.tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getTags();
|
||||
}, []);
|
||||
|
||||
function updateDatabase() {
|
||||
// split pattern string into array
|
||||
let patternArray = patternString.split(" ");
|
||||
// remove whitespace from array
|
||||
patternArray = patternArray.map((item) => item.trim());
|
||||
// remove empty strings from array
|
||||
patternArray = patternArray.filter((item) => item !== "");
|
||||
// add dot before item array
|
||||
patternArray = patternArray.map((item) => "." + item);
|
||||
|
||||
setUpdating(true);
|
||||
|
||||
fetch("/api/v1/walk", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
root: walkPath,
|
||||
pattern: patternArray,
|
||||
tag_ids: selectedTags,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert("Database updated");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setUpdating(false);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h3>{Tr("Update Database")}</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={walkPath}
|
||||
placeholder={tr("walk path", langCode)}
|
||||
onChange={(e) => setWalkPath(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={patternString}
|
||||
placeholder={tr("pattern wav flac mp3", langCode)}
|
||||
onChange={(e) => setPatternString(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<h4>{Tr("Tags")}</h4>
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id}>
|
||||
<input
|
||||
id={tag.id}
|
||||
type="checkbox"
|
||||
value={tag.id}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedTags([...selectedTags, tag.id]);
|
||||
} else {
|
||||
setSelectedTags(
|
||||
selectedTags.filter((item) => item !== tag.id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={tag.id}>{tag.name}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateDatabase();
|
||||
}}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? Tr("Updating...") : Tr("Update Database")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Database;
|
||||
102
web/src/component/EditReview.jsx
Normal file
102
web/src/component/EditReview.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { tr, Tr, langCodeContext } from "../translate";
|
||||
|
||||
function SingleReview() {
|
||||
let params = useParams();
|
||||
let navigate = useNavigate();
|
||||
const { langCode } = useContext(langCodeContext)
|
||||
|
||||
const [review, setReview] = useState({
|
||||
id: "",
|
||||
user_id: "",
|
||||
file_id: "",
|
||||
content: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
function refresh() {
|
||||
fetch("/api/v1/get_review", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setReview(data.review);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
fetch("/api/v1/update_review", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
content: review.content,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Review updated", langCode));
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteReview() {
|
||||
fetch("/api/v1/delete_review", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Review deleted", langCode));
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Edit Review")}</h3>
|
||||
<textarea
|
||||
value={review.content}
|
||||
onChange={(e) => setReview({ ...review, content: e.target.value })}
|
||||
></textarea>
|
||||
<div>
|
||||
<button onClick={() => deleteReview()}>{Tr("Delete")}</button>
|
||||
<button onClick={() => save()}>{Tr("Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SingleReview;
|
||||
143
web/src/component/EditTag.jsx
Normal file
143
web/src/component/EditTag.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { tr, Tr, langCodeContext } from "../translate";
|
||||
|
||||
function EditTag() {
|
||||
let params = useParams();
|
||||
let navigate = useNavigate();
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
const [tag, setTag] = useState({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
created_by_user: {
|
||||
id: "",
|
||||
username: "",
|
||||
role: "",
|
||||
avatar_id: "",
|
||||
},
|
||||
});
|
||||
|
||||
function refreshTagInfo() {
|
||||
fetch("/api/v1/get_tag_info", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setTag(data.tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateTagInfo() {
|
||||
fetch("/api/v1/update_tag", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
name: tag.name,
|
||||
description: tag.description,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Tag updated successfully", langCode));
|
||||
refreshTagInfo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshTagInfo();
|
||||
}, []);
|
||||
|
||||
function deleteTag() {
|
||||
fetch("/api/v1/delete_tag", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Tag deleted successfully", langCode));
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Edit Tag")}</h3>
|
||||
<div>
|
||||
<label htmlFor="id">{Tr("ID")}</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
name="id"
|
||||
id="id"
|
||||
value={tag.id}
|
||||
onChange={(e) => setTag({ ...tag, id: e.target.value })}
|
||||
/>
|
||||
<label htmlFor="name">{Tr("Created by")}</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
name="created_by_user_username"
|
||||
id="created_by_user_username"
|
||||
value={tag.created_by_user.username}
|
||||
onChange={(e) =>
|
||||
setTag({
|
||||
...tag,
|
||||
created_by_user: {
|
||||
...tag.created_by_user,
|
||||
username: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label htmlFor="name">{Tr("Name")}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
value={tag.name}
|
||||
onChange={(e) => setTag({ ...tag, name: e.target.value })}
|
||||
/>
|
||||
<label htmlFor="description">{Tr("Description")}</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="description"
|
||||
value={tag.description}
|
||||
onChange={(e) => setTag({ ...tag, description: e.target.value })}
|
||||
/>
|
||||
<button onClick={deleteTag}>{Tr("Delete")}</button>
|
||||
<button onClick={() => updateTagInfo()}>{Tr("Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditTag;
|
||||
107
web/src/component/FeedbackPage.jsx
Normal file
107
web/src/component/FeedbackPage.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { convertIntToDateTime } from "./Common";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function FeedbackPage() {
|
||||
const [content, setContext] = useState("");
|
||||
const [feedbacks, setFeedbacks] = useState([]);
|
||||
|
||||
function getFeedbacks() {
|
||||
fetch("/api/v1/get_feedbacks")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
console.log(data.error);
|
||||
} else {
|
||||
setFeedbacks(data.feedbacks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function submitFeedback() {
|
||||
fetch("/api/v1/feedback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setContext("");
|
||||
getFeedbacks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getFeedbacks();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Feedbacks")}</h3>
|
||||
<textarea value={content} onChange={(e) => setContext(e.target.value)} />
|
||||
<button onClick={() => submitFeedback()}>{Tr("Submit")}</button>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{Tr("User")}</th>
|
||||
<th>{Tr("Feedback")}</th>
|
||||
<th>{Tr("Date")}</th>
|
||||
<th>{Tr("Action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feedbacks.map((feedback) => (
|
||||
<tr key={feedback._id}>
|
||||
<td>
|
||||
<Link to={`/manage/users/${feedback.user.id}`}>
|
||||
@{feedback.user.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{feedback.content}</td>
|
||||
<td>{convertIntToDateTime(feedback.time)}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/v1/delete_feedback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: feedback.id,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
getFeedbacks();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Tr("Delete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackPage;
|
||||
42
web/src/component/FfmpegConfig.jsx
Normal file
42
web/src/component/FfmpegConfig.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function FfmpegConfig(props) {
|
||||
// props.setSelectedFfmpegConfig
|
||||
// props.selectedFfmpegConfig
|
||||
|
||||
const [ffmpegConfigList, setFfmpegConfigList] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/get_ffmpeg_config_list")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setFfmpegConfigList(data.ffmpeg_config_list);
|
||||
if (data.ffmpeg_config_list.length > 0) {
|
||||
props.setSelectedFfmpegConfig(data.ffmpeg_config_list[0]);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_ffmpeg_config_list error: " + error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="ffmpeg-config">
|
||||
<select
|
||||
onChange={(event) => {
|
||||
props.setSelectedFfmpegConfig(
|
||||
ffmpegConfigList[event.target.selectedIndex]
|
||||
);
|
||||
}}
|
||||
>
|
||||
{ffmpegConfigList.map((ffmpegConfig) => (
|
||||
<option key={ffmpegConfig.name}>{ffmpegConfig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="warp-word">{props.selectedFfmpegConfig.args}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FfmpegConfig;
|
||||
58
web/src/component/FileDialog.jsx
Normal file
58
web/src/component/FileDialog.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function FileDialog(props) {
|
||||
// props.showStatus
|
||||
// props.setShowStatus
|
||||
// props.playingFile
|
||||
// props.setPlayingFile
|
||||
// props.file
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
open={props.showStatus}
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setPlayingFile(props.file);
|
||||
props.setShowStatus(false);
|
||||
}}
|
||||
>
|
||||
{props.file.filename}
|
||||
</p>
|
||||
<p>
|
||||
{Tr("Play: play using browser player.")}
|
||||
<br />
|
||||
{Tr("Info for more actions.")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/files/${props.file.id}`);
|
||||
props.setShowStatus(false);
|
||||
}}
|
||||
>
|
||||
{Tr("Info")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
props.setPlayingFile(props.file);
|
||||
props.setShowStatus(false);
|
||||
}}
|
||||
>
|
||||
{Tr("Play")}
|
||||
</button>
|
||||
<button onClick={() => props.setShowStatus(false)}>{Tr("Close")}</button>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDialog;
|
||||
46
web/src/component/FileEntry.jsx
Normal file
46
web/src/component/FileEntry.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CalcReadableFilesize } from "./Common";
|
||||
import FileDialog from "./FileDialog";
|
||||
|
||||
function FileEntry(props) {
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
className="clickable"
|
||||
onClick={() => {
|
||||
// double click to play file and close dialog
|
||||
if (showStatus) {
|
||||
props.setPlayingFile(props.file);
|
||||
setShowStatus(false);
|
||||
return;
|
||||
}
|
||||
setShowStatus(true);
|
||||
}}
|
||||
>
|
||||
{props.file.filename}
|
||||
</td>
|
||||
<td
|
||||
className="clickable"
|
||||
onClick={() => navigate(`/folders/${props.file.folder_id}`)}
|
||||
>
|
||||
{props.file.foldername}
|
||||
</td>
|
||||
<td>
|
||||
{CalcReadableFilesize(props.file.filesize)}
|
||||
<FileDialog
|
||||
setPlayingFile={props.setPlayingFile}
|
||||
showStatus={showStatus}
|
||||
setShowStatus={setShowStatus}
|
||||
file={props.file}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileEntry;
|
||||
331
web/src/component/FileInfo.jsx
Normal file
331
web/src/component/FileInfo.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as React from 'react';
|
||||
import {useNavigate, useParams} from "react-router";
|
||||
import {useContext, useEffect, useState} from "react";
|
||||
import {Tr, tr, langCodeContext} from "../translate";
|
||||
|
||||
function FileInfo(props) {
|
||||
let navigate = useNavigate();
|
||||
let params = useParams();
|
||||
const [file, setFile] = useState({
|
||||
id: "",
|
||||
folder_id: "",
|
||||
foldername: "",
|
||||
filename: "",
|
||||
filesize: "",
|
||||
});
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagsOnFile, setTagsOnFile] = useState([]);
|
||||
const [selectedTagID, setSelectedTagID] = useState("");
|
||||
const {langCode} = useContext(langCodeContext);
|
||||
const [ffprobeInfo, setFfprobeInfo] = useState("");
|
||||
|
||||
function refresh() {
|
||||
fetch(`/api/v1/get_file_info`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setFile(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTags() {
|
||||
fetch(`/api/v1/get_tags`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setTags(data.tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTagsOnFile() {
|
||||
fetch(`/api/v1/get_tags_on_file`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setTagsOnFile(data.tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeTagOnFile(tag_id) {
|
||||
fetch(`/api/v1/delete_tag_on_file`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_id: parseInt(params.id),
|
||||
tag_id: tag_id,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
getTagsOnFile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFile() {
|
||||
// show Warning
|
||||
if (
|
||||
window.confirm(tr("Are you sure you want to delete this file?", langCode))
|
||||
) {
|
||||
fetch(`/api/v1/delete_file`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilename() {
|
||||
fetch(`/api/v1/update_filename`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
filename: file.filename,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Filename updated", langCode));
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetFilename() {
|
||||
fetch(`/api/v1/reset_filename`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
getTags();
|
||||
getTagsOnFile();
|
||||
}, []);
|
||||
|
||||
const downloadURL = "/api/v1/get_file_direct?id=" + file.id;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("File Details")}</h3>
|
||||
<div>
|
||||
<a href={downloadURL} download>
|
||||
<button>{Tr("Download")}</button>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
props.setPlayingFile(file);
|
||||
}}
|
||||
>
|
||||
{Tr("Play")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/files/${params.id}/review`);
|
||||
}}
|
||||
>
|
||||
{Tr("Review")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/files/${params.id}/share`);
|
||||
}}
|
||||
>
|
||||
{Tr("Share")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteFile();
|
||||
}}
|
||||
>
|
||||
{Tr("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="foldername">{Tr("Folder Name")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="foldername"
|
||||
value={file.foldername}
|
||||
onClick={() => {
|
||||
navigate(`/folders/${file.folder_id}`);
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
<label htmlFor="filename">{Tr("Filename")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="filename"
|
||||
value={file.filename}
|
||||
onChange={(event) => {
|
||||
setFile({
|
||||
...file,
|
||||
filename: event.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="filesize">{Tr("File size")}</label>
|
||||
<input type="text" id="filesize" value={file.filesize} readOnly />
|
||||
</div>
|
||||
<div className="horizontal">
|
||||
<button onClick={updateFilename}>{Tr("Save")}</button>
|
||||
<button onClick={resetFilename}>{Tr("Reset")}</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>{Tr("Tags")}</label>
|
||||
<ul>
|
||||
{tagsOnFile.map((tag) => {
|
||||
return (
|
||||
<li key={tag.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/manage/tags/${tag.id}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
removeTagOnFile(tag.id);
|
||||
}}
|
||||
>
|
||||
{Tr("Remove")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
setSelectedTagID(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">{tr("Select a tag", langCode)}</option>
|
||||
{tags.map((tag) => {
|
||||
return (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
// check empty
|
||||
if (selectedTagID === "") {
|
||||
alert(tr("Please select a tag", langCode));
|
||||
return;
|
||||
}
|
||||
fetch(`/api/v1/put_tag_on_file`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_id: parseInt(params.id),
|
||||
tag_id: parseInt(selectedTagID),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
getTagsOnFile();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Tr("Add tag")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={async () => {
|
||||
const resp = await fetch(`/api/v1/get_file_ffprobe_info`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
});
|
||||
const text = await resp.text();
|
||||
setFfprobeInfo(text);
|
||||
}}>FFprobe</button>
|
||||
|
||||
{ffprobeInfo && <textarea
|
||||
style={{
|
||||
height: "30em",
|
||||
}}
|
||||
>{ffprobeInfo}</textarea>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileInfo;
|
||||
141
web/src/component/FilesInFolder.jsx
Normal file
141
web/src/component/FilesInFolder.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react';
|
||||
import {useParams} from "react-router";
|
||||
import {useState, useEffect} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {useQuery} from "./Common";
|
||||
import FilesTable from "./FilesTable";
|
||||
import {Tr} from "../translate";
|
||||
|
||||
function FilesInFolder(props) {
|
||||
let params = useParams();
|
||||
const query = useQuery();
|
||||
const navigator = useNavigate();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const offset = parseInt(query.get("o")) || 0;
|
||||
const [newFoldername, setNewFoldername] = useState("");
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const limit = 10;
|
||||
|
||||
function refresh() {
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/get_files_in_folder", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
folder_id: parseInt(params.id),
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setFiles(data.files);
|
||||
setFolderPath(data.folder);
|
||||
if (data.files.length > 0) {
|
||||
setNewFoldername(data.files[0].foldername);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => alert(error))
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [params.id, offset]);
|
||||
|
||||
function nextPage() {
|
||||
navigator(`/folders/${params.id}?o=${offset + limit}`);
|
||||
}
|
||||
|
||||
function lastPage() {
|
||||
const offsetValue = offset - limit;
|
||||
if (offsetValue < 0) {
|
||||
return;
|
||||
}
|
||||
navigator(`/folders/${params.id}?o=${offsetValue}`);
|
||||
}
|
||||
|
||||
function updateFoldername() {
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/update_foldername", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
foldername: newFoldername,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
})
|
||||
.catch((error) => alert(error))
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function resetFoldername() {
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/reset_foldername", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
})
|
||||
.catch((error) => alert(error))
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Files in Folder")}</h3>
|
||||
<div className="search_toolbar">
|
||||
<button onClick={lastPage}>{Tr("Last page")}</button>
|
||||
<button disabled>
|
||||
{isLoading
|
||||
? Tr("Loading...")
|
||||
: `${offset} - ${offset + files.length}`}
|
||||
</button>
|
||||
<button onClick={nextPage}>{Tr("Next page")}</button>
|
||||
</div>
|
||||
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
|
||||
<span>{folderPath}</span>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newFoldername}
|
||||
onChange={(e) => setNewFoldername(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<button onClick={() => updateFoldername()}>{Tr("Save")}</button>
|
||||
<button onClick={() => resetFoldername()}>{Tr("Reset")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesInFolder;
|
||||
31
web/src/component/FilesTable.jsx
Normal file
31
web/src/component/FilesTable.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import FileEntry from "./FileEntry";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function FilesTable(props) {
|
||||
if (props.files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{Tr("Filename")}</th>
|
||||
<th>{Tr("Folder Name")}</th>
|
||||
<th>{Tr("Size")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.files.map((file) => (
|
||||
<FileEntry
|
||||
setPlayingFile={props.setPlayingFile}
|
||||
key={file.id}
|
||||
file={file}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesTable;
|
||||
37
web/src/component/FoldersTable.jsx
Normal file
37
web/src/component/FoldersTable.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function FoldersTable(props) {
|
||||
let navigate = useNavigate();
|
||||
if (props.folders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{Tr("Folder name")}</th>
|
||||
<th>{Tr("Action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.folders.map((folder) => (
|
||||
<tr key={folder.id}>
|
||||
<td
|
||||
onClick={() => navigate(`/folders/${folder.id}`)}
|
||||
className="clickable"
|
||||
>
|
||||
{folder.foldername}
|
||||
</td>
|
||||
<td onClick={() => navigate(`/folders/${folder.id}`)}>
|
||||
<button>{Tr("View")}</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default FoldersTable;
|
||||
118
web/src/component/GetRandomFiles.jsx
Normal file
118
web/src/component/GetRandomFiles.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "./Common";
|
||||
import FilesTable from "./FilesTable";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function GetRandomFiles(props) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tags, setTags] = useState([]);
|
||||
const navigator = useNavigate();
|
||||
const query = useQuery();
|
||||
const selectedTag = query.get("t") || "";
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
const fetchRandomFiles = async () => {
|
||||
const resp = await fetch("/api/v1/get_random_files");
|
||||
const json = await resp.json();
|
||||
return json.files;
|
||||
};
|
||||
|
||||
async function getRandomFiles() {
|
||||
setIsLoading(true);
|
||||
fetchRandomFiles()
|
||||
.then((data) => {
|
||||
setFiles(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_random_files error: " + error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchRandomFilesWithTag = async (selectedTag) => {
|
||||
const resp = await fetch("/api/v1/get_random_files_with_tag", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(selectedTag),
|
||||
}),
|
||||
});
|
||||
const json = await resp.json();
|
||||
return json.files;
|
||||
};
|
||||
|
||||
function getRandomFilesWithTag() {
|
||||
setIsLoading(true);
|
||||
fetchRandomFilesWithTag(selectedTag)
|
||||
.then((files) => {
|
||||
setFiles(files);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_random_files_with_tag error: " + error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (selectedTag === "") {
|
||||
getRandomFiles();
|
||||
} else {
|
||||
getRandomFilesWithTag();
|
||||
}
|
||||
}
|
||||
|
||||
function getTags() {
|
||||
fetch("/api/v1/get_tags")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setTags(data.tags);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_tags error: " + error);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [selectedTag]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="search_toolbar">
|
||||
<button className="refresh" onClick={() => refresh(setFiles)}>
|
||||
{isLoading ? Tr("Loading...") : Tr("Refresh")}
|
||||
</button>
|
||||
<select
|
||||
className="tag_select"
|
||||
onChange={(event) => {
|
||||
navigator(`/?t=${event.target.value}`);
|
||||
}}
|
||||
value={selectedTag}
|
||||
>
|
||||
<option value="">{tr("All", langCode)}</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GetRandomFiles;
|
||||
75
web/src/component/Login.jsx
Normal file
75
web/src/component/Login.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useContext, useState } from "react";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function Login(props) {
|
||||
let navigate = useNavigate();
|
||||
let [username, setUsername] = useState("");
|
||||
let [password, setPassword] = useState("");
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function login() {
|
||||
if (!username || !password) {
|
||||
alert(tr("Please enter username and password", langCode));
|
||||
return;
|
||||
}
|
||||
fetch("/api/v1/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
props.setUser(data.user);
|
||||
navigate("/");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>{Tr("Login")}</h2>
|
||||
<label htmlFor="username">{Tr("Username")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password">{Tr("Password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
login();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<button onClick={login}>{Tr("Login")}</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/manage/register");
|
||||
}}
|
||||
>
|
||||
{Tr("Register")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
101
web/src/component/Manage.jsx
Normal file
101
web/src/component/Manage.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router";
|
||||
import Database from "./Database";
|
||||
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "../translate";
|
||||
import { useContext } from "react";
|
||||
|
||||
function Manage(props) {
|
||||
let navigate = useNavigate();
|
||||
const { langCode, setLangCode } = useContext(langCodeContext);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>{Tr("Manage")}</h2>
|
||||
<p>
|
||||
{Tr("Hi")}, {props.user.username}
|
||||
</p>
|
||||
|
||||
<select
|
||||
value={langCode}
|
||||
onChange={(event) => {
|
||||
setLangCode(event.target.value);
|
||||
}}
|
||||
>
|
||||
{Object.keys(LANG_OPTIONS).map((code) => {
|
||||
const langOption = LANG_OPTIONS[code];
|
||||
return (
|
||||
<option value={code} key={code}>
|
||||
{langOption.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
{props.user.role === 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/manage/login");
|
||||
}}
|
||||
>
|
||||
{Tr("Login")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/manage/register");
|
||||
}}
|
||||
>
|
||||
{Tr("Register")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{props.user.role !== 0 && (
|
||||
<div className="horizontal">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/manage/users/${props.user.id}`);
|
||||
}}
|
||||
>
|
||||
{Tr("Profile")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/v1/logout")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
props.setUser(data.user);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Tr("Logout")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
<div className="horizontal">
|
||||
<button onClick={() => navigate("/manage/tags")}>{Tr("Tags")}</button>
|
||||
<button onClick={() => navigate("/manage/users")}>{Tr("Users")}</button>
|
||||
<button onClick={() => navigate("/manage/feedbacks")}>
|
||||
{Tr("Feedbacks")}
|
||||
</button>
|
||||
</div>
|
||||
<Database />
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/heimoshuiyu/msw-open-music"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{Tr("View source code on Github")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Manage;
|
||||
82
web/src/component/ManageUser.jsx
Normal file
82
web/src/component/ManageUser.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function ManageUser() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const roleDict = {
|
||||
0: "Anonymous",
|
||||
1: "Admin",
|
||||
2: "User",
|
||||
};
|
||||
|
||||
function getUsers() {
|
||||
fetch("/api/v1/get_users")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setUsers(data.users);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Manage User")}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{Tr("Name")}</th>
|
||||
<th>{Tr("Role")}</th>
|
||||
<th>{Tr("Active")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<Link to={`/manage/users/${user.id}`}>@{user.username}</Link>
|
||||
</td>
|
||||
<td>{Tr(roleDict[user.role])}</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={user.active}
|
||||
onClick={(e) => {
|
||||
fetch("/api/v1/update_user_active", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: user.id,
|
||||
active: e.target.checked,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
getUsers();
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageUser;
|
||||
83
web/src/component/Register.jsx
Normal file
83
web/src/component/Register.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useContext, useState } from "react";
|
||||
import { tr, Tr, langCodeContext } from "../translate";
|
||||
|
||||
function Register() {
|
||||
let navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [password2, setPassword2] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function register() {
|
||||
if (!username || !password || !password2 || !role) {
|
||||
alert(tr("Please fill out all fields", langCode));
|
||||
} else if (password !== password2) {
|
||||
alert(tr("Password do not match", langCode));
|
||||
} else {
|
||||
fetch("/api/v1/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
role: parseInt(role),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
navigate("/manage/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>{Tr("Register")}</h2>
|
||||
<label htmlFor="username">{Tr("Username")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password">{Tr("Password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password2">{Tr("Confirm Password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password2"
|
||||
value={password2}
|
||||
onChange={(e) => setPassword2(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
register();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="role">{Tr("Role")}</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="">{tr("Select a role", langCode)}</option>
|
||||
<option value="2">{tr("User", langCode)}</option>
|
||||
<option value="1">{tr("Admin", langCode)}</option>
|
||||
</select>
|
||||
<button onClick={register}>{Tr("Register")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
34
web/src/component/ReviewEntry.jsx
Normal file
34
web/src/component/ReviewEntry.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from "react-router-dom";
|
||||
import { convertIntToDateTime } from "./Common";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
import { useContext } from "react";
|
||||
|
||||
function ReviewEntry(props) {
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
return (
|
||||
<div>
|
||||
<h4>
|
||||
<Link to={`/manage/users/${props.review.user.id}`}>
|
||||
@{props.review.user.username}
|
||||
</Link>{" "}
|
||||
{Tr("review")}{" "}
|
||||
<Link to={`/files/${props.review.file.id}`}>
|
||||
{props.review.file.filename}
|
||||
</Link>{" "}
|
||||
{Tr("on")} {convertIntToDateTime(props.review.created_at)}{" "}
|
||||
{props.review.updated_at !== 0 &&
|
||||
`(${tr("modified on", langCode)} ${convertIntToDateTime(
|
||||
props.review.updated_at
|
||||
)} ) `}
|
||||
{(props.user.role === 1 || props.review.user.id === props.user.id) &&
|
||||
props.user.role !== 0 && (
|
||||
<Link to={`/manage/reviews/${props.review.id}`}>{Tr("Edit")}</Link>
|
||||
)}
|
||||
</h4>
|
||||
<p>{props.review.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReviewEntry;
|
||||
77
web/src/component/ReviewPage.jsx
Normal file
77
web/src/component/ReviewPage.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import ReviewEntry from "./ReviewEntry";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function ReviewPage(props) {
|
||||
let params = useParams();
|
||||
const [newReview, setNewReview] = useState("");
|
||||
const [reviews, setReviews] = useState([]);
|
||||
|
||||
function refresh() {
|
||||
fetch("/api/v1/get_reviews_on_file", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setReviews(data.reviews);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
function submitReview() {
|
||||
fetch("/api/v1/insert_review", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: newReview,
|
||||
file_id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setNewReview("");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Review Page")}</h3>
|
||||
<div>
|
||||
{reviews.map((review) => (
|
||||
<ReviewEntry key={review.id} review={review} user={props.user} />
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
value={newReview}
|
||||
onChange={(e) => setNewReview(e.target.value)}
|
||||
/>
|
||||
<button onClick={() => submitReview()}>{Tr("Submit")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReviewPage;
|
||||
94
web/src/component/SearchFiles.jsx
Normal file
94
web/src/component/SearchFiles.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "./Common";
|
||||
import FilesTable from "./FilesTable";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function SearchFiles(props) {
|
||||
const navigator = useNavigate();
|
||||
const [files, setFiles] = useState([]);
|
||||
const query = useQuery();
|
||||
const filename = query.get("q") || "";
|
||||
const [filenameInput, setFilenameInput] = useState(filename);
|
||||
const offset = parseInt(query.get("o")) || 0;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const limit = 10;
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function searchFiles() {
|
||||
// check empty filename
|
||||
if (filename === "") {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/search_files", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: filename,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const files = data.files ? data.files : [];
|
||||
setFiles(files);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("search_files error: " + error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
navigator(`/files?q=${filenameInput}&o=${offset + limit}`);
|
||||
}
|
||||
|
||||
function lastPage() {
|
||||
const offsetValue = offset - limit;
|
||||
if (offsetValue < 0) {
|
||||
return;
|
||||
}
|
||||
navigator(`/files?q=${filenameInput}&o=${offsetValue}`);
|
||||
}
|
||||
|
||||
useEffect(() => searchFiles(), [offset, filename]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Search Files")}</h3>
|
||||
<div className="search_toolbar">
|
||||
<input
|
||||
onChange={(event) => setFilenameInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
navigator(`/files?q=${filenameInput}&o=0`);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
placeholder={tr("Enter filename", langCode)}
|
||||
value={filenameInput}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator(`/files?q=${filenameInput}&o=0`);
|
||||
}}
|
||||
>
|
||||
{isLoading ? Tr("Loading...") : Tr("Search")}
|
||||
</button>
|
||||
<button onClick={lastPage}>{Tr("Last page")}</button>
|
||||
<button disabled>
|
||||
{offset} - {offset + files.length}
|
||||
</button>
|
||||
<button onClick={nextPage}>{Tr("Next page")}</button>
|
||||
</div>
|
||||
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFiles;
|
||||
92
web/src/component/SearchFolders.jsx
Normal file
92
web/src/component/SearchFolders.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "./Common";
|
||||
import FoldersTable from "./FoldersTable";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function SearchFolders() {
|
||||
const navigator = useNavigate();
|
||||
const query = useQuery();
|
||||
const foldername = query.get("q") || "";
|
||||
const [foldernameInput, setFoldernameInput] = useState(foldername);
|
||||
const [folders, setFolders] = useState([]);
|
||||
const offset = parseInt(query.get("o")) || 0;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const limit = 10;
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function searchFolder() {
|
||||
if (foldername === "") {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/search_folders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
foldername: foldername,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setFolders(data.folders ? data.folders : []);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("search_folders error: " + error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
navigator(`/folders?q=${foldername}&o=${offset + limit}`);
|
||||
}
|
||||
|
||||
function lastPage() {
|
||||
const offsetValue = offset - limit;
|
||||
if (offsetValue < 0) {
|
||||
return;
|
||||
}
|
||||
navigator(`/folders?q=${foldername}&o=${offsetValue}`);
|
||||
}
|
||||
|
||||
useEffect(() => searchFolder(), [offset, foldername]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Search Folders")}</h3>
|
||||
<div className="search_toolbar">
|
||||
<input
|
||||
onChange={(event) => setFoldernameInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
navigator(`/folders?q=${foldernameInput}&o=0`);
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
placeholder={tr("Enter folder name", langCode)}
|
||||
value={foldernameInput}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator(`/folders?q=${foldernameInput}&o=0`);
|
||||
}}
|
||||
>
|
||||
{isLoading ? Tr("Loading...") : Tr("Search")}
|
||||
</button>
|
||||
<button onClick={lastPage}>{Tr("Last page")}</button>
|
||||
<button disabled>
|
||||
{offset} - {offset + limit}
|
||||
</button>
|
||||
<button onClick={nextPage}>{Tr("Next page")}</button>
|
||||
</div>
|
||||
<FoldersTable folders={folders} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFolders;
|
||||
59
web/src/component/Share.jsx
Normal file
59
web/src/component/Share.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import FilesTable from "./FilesTable";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function Share(props) {
|
||||
let params = useParams();
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
const [file, setFile] = useState({});
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/get_file_info", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setFile(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_file_info error: " + error);
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
// change title
|
||||
useEffect(() => {
|
||||
const oldTitle = document.title;
|
||||
|
||||
document.title = `${tr("Share", langCode)}🎵: ${
|
||||
file.filename
|
||||
} - MSW Open Music`;
|
||||
|
||||
// set title back
|
||||
return () => {
|
||||
document.title = oldTitle;
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Share with others!")}</h3>
|
||||
<p>
|
||||
{Tr("Share link")}:{" "}
|
||||
<a href={window.location.href}>{window.location.href}</a>
|
||||
</p>
|
||||
<p>
|
||||
👇 {Tr("Click the filename below to enjoy music!")}
|
||||
<br />
|
||||
</p>
|
||||
<FilesTable setPlayingFile={props.setPlayingFile} files={[file]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Share;
|
||||
107
web/src/component/Tags.jsx
Normal file
107
web/src/component/Tags.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tr } from "../translate";
|
||||
|
||||
function Tags() {
|
||||
const [tags, setTags] = useState([]);
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
const [newTagDescription, setNewTagDescription] = useState("");
|
||||
const [showAddTag, setShowAddTag] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
fetch("/api/v1/get_tags")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setTags(data.tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("Tags")}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{Tr("Name")}</th>
|
||||
<th>{Tr("Description")}</th>
|
||||
<th>{Tr("Created by")}</th>
|
||||
<th>{Tr("Action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag) => (
|
||||
<tr key={tag.id}>
|
||||
<td>{tag.name}</td>
|
||||
<td>{tag.description}</td>
|
||||
<td>
|
||||
<Link to={`/manage/users/${tag.created_by_user.id}`}>
|
||||
@{tag.created_by_user.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link to={`/manage/tags/${tag.id}`}>{Tr("Edit")}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!showAddTag && (
|
||||
<button onClick={() => setShowAddTag(true)}>{Tr("Add tag")}</button>
|
||||
)}
|
||||
{showAddTag && (
|
||||
<div>
|
||||
<label htmlFor="newTagName">{Tr("New Tag Name")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newTagName"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="newTagDescription">{Tr("New Tag Description")}</label>
|
||||
<textarea
|
||||
id="newTagDescription"
|
||||
value={newTagDescription}
|
||||
onChange={(e) => setNewTagDescription(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/v1/insert_tag", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newTagName,
|
||||
description: newTagDescription,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setNewTagName("");
|
||||
setNewTagDescription("");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Tr("Create tag")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tags;
|
||||
171
web/src/component/UserProfile.jsx
Normal file
171
web/src/component/UserProfile.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import ReviewEntry from "./ReviewEntry";
|
||||
import { Tr, tr, langCodeContext } from "../translate";
|
||||
|
||||
function UserProfile(props) {
|
||||
let params = useParams();
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||
const [user, setUser] = useState({
|
||||
id: 0,
|
||||
username: "",
|
||||
role: 0,
|
||||
active: false,
|
||||
avatar_id: 0,
|
||||
});
|
||||
const { langCode } = useContext(langCodeContext);
|
||||
|
||||
function getReviews() {
|
||||
fetch("/api/v1/get_reviews_by_user", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setReviews(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getUserInfo() {
|
||||
fetch("/api/v1/get_user_info", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setUser(data.user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getReviews();
|
||||
getUserInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h3>{Tr("User Profile")}</h3>
|
||||
<div className="horizontal">
|
||||
<input
|
||||
type="text"
|
||||
value={user.username}
|
||||
onChange={(e) => {
|
||||
setUser({
|
||||
...user,
|
||||
username: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/v1/update_username", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
username: user.username,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
props.setUser({
|
||||
...props.user,
|
||||
username: user.username,
|
||||
});
|
||||
alert("Username updated successfully!");
|
||||
getUserInfo();
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={props.user.id !== user.id && props.user.role !== 1}
|
||||
>
|
||||
{Tr("Save username")}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
placeholder={tr("Old password", langCode)}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
placeholder={tr("New password", langCode)}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPasswordConfirm}
|
||||
placeholder={tr("Confirm new password", langCode)}
|
||||
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/v1/update_user_password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(params.id),
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
new_password_confirm: newPasswordConfirm,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert(tr("Password updated successfully!", langCode));
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
(props.user.id !== user.id && props.user.role !== 1) ||
|
||||
newPassword !== newPasswordConfirm ||
|
||||
newPassword.length === 0
|
||||
}
|
||||
>
|
||||
{Tr("Change password")}
|
||||
</button>
|
||||
</div>
|
||||
<h4>{Tr("Reviews")}</h4>
|
||||
{reviews.map((review) => (
|
||||
<ReviewEntry key={review.id} review={review} user={props.user} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserProfile;
|
||||
17
web/src/component/UserStatus.jsx
Normal file
17
web/src/component/UserStatus.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function UserStatus(props) {
|
||||
// props.user
|
||||
// props.setUser
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/login")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
props.setUser(data.user);
|
||||
});
|
||||
}, []);
|
||||
return <div>{props.user.username}</div>;
|
||||
}
|
||||
|
||||
export default UserStatus;
|
||||
13
web/src/index.css
Normal file
13
web/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
12
web/src/index.jsx
Normal file
12
web/src/index.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import 'water.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
5
web/src/setupTests.js
Normal file
5
web/src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
45
web/src/translate/index.jsx
Normal file
45
web/src/translate/index.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { createContext } from "react";
|
||||
import MAP_zh_CN from "./zh_CN";
|
||||
|
||||
const LANG_OPTIONS = {
|
||||
"en-US": {
|
||||
name: "English",
|
||||
langMap: {},
|
||||
matches: ["en-US", "en"],
|
||||
},
|
||||
"zh-CN": {
|
||||
name: "中文(简体)",
|
||||
langMap: MAP_zh_CN,
|
||||
matches: ["zh-CN", "zh"],
|
||||
},
|
||||
};
|
||||
|
||||
const langCodeContext = createContext("en-US");
|
||||
|
||||
function tr(text, langCode) {
|
||||
const option = LANG_OPTIONS[langCode];
|
||||
if (option === undefined) {
|
||||
return text;
|
||||
}
|
||||
const langMap = LANG_OPTIONS[langCode].langMap;
|
||||
|
||||
const translatedText = langMap[text.toLowerCase()];
|
||||
if (translatedText === undefined) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
function Tr(text) {
|
||||
return (
|
||||
<langCodeContext.Consumer>
|
||||
{({ langCode }) => {
|
||||
return tr(text, langCode);
|
||||
}}
|
||||
</langCodeContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
export { tr, Tr, LANG_OPTIONS, langCodeContext };
|
||||
107
web/src/translate/zh_CN.js
Normal file
107
web/src/translate/zh_CN.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const LANG_zh_CN = {
|
||||
"feeling luckly": "随机",
|
||||
files: "文件",
|
||||
folders: "文件夹",
|
||||
manage: "管理",
|
||||
"manage user": "用户管理",
|
||||
active: "激活",
|
||||
"search files": "搜索文件",
|
||||
"search folders": "搜索文件夹",
|
||||
"enter filename": "输入文件名",
|
||||
"enter folder name": "输入文件名",
|
||||
search: "搜索",
|
||||
"last page": "上一页",
|
||||
all: "全部",
|
||||
"loading...": "加载中...",
|
||||
"next page": "下一页",
|
||||
"search polders": "搜索文件夹",
|
||||
"share with others!": "分享给好友!",
|
||||
"click the filename below to enjoy music!": "点击下面的文件名开始享受音乐!",
|
||||
"share link": "分享链接",
|
||||
hi: "您好",
|
||||
profile: "个人信息",
|
||||
"user profile": "用户信息",
|
||||
"save username": "更改用户名",
|
||||
save: "保存",
|
||||
reset: "重置",
|
||||
"old password": "旧密码",
|
||||
"new password": "新密码",
|
||||
"confirm new password": "确认新密码",
|
||||
"change password": "更改密码",
|
||||
reviews: "评论",
|
||||
review: "评论",
|
||||
on: "在",
|
||||
edit: "编辑",
|
||||
"modified on": "修改于",
|
||||
share: "分享",
|
||||
delete: "删除",
|
||||
remove: "移除",
|
||||
"file details": "文件详情",
|
||||
download: "下载",
|
||||
logout: "登出",
|
||||
tags: "标签",
|
||||
"add tag": "添加标签",
|
||||
"select a tag": "选择一个标签",
|
||||
"review page": "评论页面",
|
||||
submit: "提交",
|
||||
users: "用户",
|
||||
feedbacks: "反馈",
|
||||
feedback: "反馈",
|
||||
date: "时间",
|
||||
action: "操作",
|
||||
"new tag name": "新标签名",
|
||||
"new tag description": "新标签描述",
|
||||
"update database": "更新索引",
|
||||
"updating...": "更新中...",
|
||||
refresh: "刷新",
|
||||
filename: "文件名",
|
||||
"folder name": "文件夹名",
|
||||
size: "大小",
|
||||
"player status": "播放状态",
|
||||
play: "播放",
|
||||
stop: "停止",
|
||||
"stop timer": "定时停止",
|
||||
loop: "循环",
|
||||
raw: "无损",
|
||||
prepare: "预转码",
|
||||
"file size": "文件大小",
|
||||
login: "登陆",
|
||||
register: "注册",
|
||||
"play: play using browser player.": "播放: 使用浏览器播放",
|
||||
"info for more actions.": "详细: 查看更多相关信息",
|
||||
info: "详细",
|
||||
close: "关闭",
|
||||
"please enter username and password": "请输入用户名和密码",
|
||||
username: "用户名",
|
||||
password: "密码",
|
||||
"please fill out all fields": "请完整填写所有信息",
|
||||
"password do not match": "两次密码不一致",
|
||||
"password updated successfully!": "密码已成功更新!",
|
||||
role: "身份",
|
||||
user: "用户",
|
||||
admin: "管理员",
|
||||
anonymous: "匿名",
|
||||
"select a role": "选择身份",
|
||||
"walk path": "遍历目录",
|
||||
"pattern wav flac mp3": "拓展名 wav flac mp3",
|
||||
"review updated": "已修改评论",
|
||||
"review deleted": "已删除评论",
|
||||
"edit review": "编辑评论",
|
||||
view: "查看",
|
||||
"tag updated successfully": "标签修改成功",
|
||||
"tag deleted successfully": "标签删除成功",
|
||||
"edit tag": "编辑标签",
|
||||
id: "编号",
|
||||
"created by": "创建者",
|
||||
"create tag": "创建新标签",
|
||||
name: "名称",
|
||||
description: "描述",
|
||||
"are you sure you want to delete this file?": "你确定要删除这个文件吗?",
|
||||
"filename updated": "已修改文件名",
|
||||
"please select a tag": "请选择一个标签",
|
||||
"files in folder": "文件夹内",
|
||||
"preparing...": "转码中...",
|
||||
"view source code on github": "在 Github 上查看源代码",
|
||||
};
|
||||
|
||||
export default LANG_zh_CN;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user