Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
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": "music.sqlite3",
|
||||
"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/mattn/go-sqlite3 v1.14.14
|
||||
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/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
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())
|
||||
}
|
||||
|
||||
105
pkg/api/api.go
Normal file
105
pkg/api/api.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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_stream_direct", api.HandleGetFileStreamDirect)
|
||||
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.LoginAsAnonymous)
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
126
pkg/api/handle_get_file_info.go
Normal file
126
pkg/api/handle_get_file_info.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
// /get_file
|
||||
// get raw file with io.Copy method
|
||||
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)
|
||||
}
|
||||
|
||||
// /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)
|
||||
}
|
||||
50
pkg/api/handle_get_files_in_folder.go
Normal file
50
pkg/api/handle_get_files_in_folder.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
322
pkg/api/handle_user.go
Normal file
322
pkg/api/handle_user.go
Normal file
@@ -0,0 +1,322 @@
|
||||
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) LoginAsAnonymous(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := api.Db.LoginAsAnonymous()
|
||||
if err != nil {
|
||||
api.HandleError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
session, _ := api.store.Get(r, api.defaultSessionName)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
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("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,
|
||||
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")
|
||||
)
|
||||
399
pkg/database/method.go
Normal file
399
pkg/database/method.go
Normal file
@@ -0,0 +1,399 @@
|
||||
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, 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)
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
fileID, err := database.Insert(path, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
err = database.PutTagOnFile(tag.ID, fileID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
110
pkg/database/method_tag.go
Normal file
110
pkg/database/method_tag.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package database
|
||||
|
||||
import "errors"
|
||||
|
||||
func (database *Database) InsertTag(tag *Tag) (int64, error) {
|
||||
database.singleThreadLock.Lock()
|
||||
defer database.singleThreadLock.Unlock()
|
||||
|
||||
result, err := database.stmt.insertTag.Exec(tag.Name, tag.Description, tag.CreatedByUserId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
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
|
||||
}
|
||||
776
pkg/database/sql_stmt.go
Normal file
776
pkg/database/sql_stmt.go
Normal file
@@ -0,0 +1,776 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
var initFilesTableQuery = `CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
folder_id INTEGER NOT NULL,
|
||||
realname TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
filesize INTEGER NOT NULL,
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id)
|
||||
);`
|
||||
|
||||
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,
|
||||
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
|
||||
var initUsersTableQuery = `CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role INTEGER NOT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
avatar_id INTEGER NOT NULL,
|
||||
FOREIGN KEY(avatar_id) REFERENCES avatars(id)
|
||||
);`
|
||||
|
||||
var initAvatarsTableQuery = `CREATE TABLE IF NOT EXISTS avatars (
|
||||
id INTEGER PRIMARY KEY,
|
||||
avatarname TEXT NOT NULL,
|
||||
avatar BLOB NOT NULL
|
||||
);`
|
||||
|
||||
var initTagsTableQuery = `CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
FOREIGN KEY(created_by_user_id) REFERENCES users(id)
|
||||
);`
|
||||
|
||||
var initFileHasTagTableQuery = `CREATE TABLE IF NOT EXISTS file_has_tag (
|
||||
file_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (file_id, tag_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
FOREIGN KEY (file_id) REFERENCES files(id),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id)
|
||||
);`
|
||||
|
||||
var initLikesTableQuery = `CREATE TABLE IF NOT EXISTS likes (
|
||||
user_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, file_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
);`
|
||||
|
||||
var initReviewsTableQuery = `CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
);`
|
||||
|
||||
var initPlaybacksTableQuery = `CREATE TABLE IF NOT EXISTS playbacks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
time INTEGER NOT NULL,
|
||||
mothod INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
);`
|
||||
|
||||
var initLogsTableQuery = `CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);`
|
||||
|
||||
var initTmpfsTableQuery = `CREATE TABLE IF NOT EXISTS tmpfs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
ffmpeg_config TEXT NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
accessed_time INTEGER NOT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
);`
|
||||
|
||||
var insertFolderQuery = `INSERT INTO folders (folder, foldername)
|
||||
VALUES (?, ?);`
|
||||
|
||||
var findFolderQuery = `SELECT id FROM folders WHERE folder = ? LIMIT 1;`
|
||||
|
||||
var findFileQuery = `SELECT id FROM files WHERE folder_id = ? AND realname = ? LIMIT 1;`
|
||||
|
||||
var insertFileQuery = `INSERT INTO files (folder_id, realname, 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 ?
|
||||
ORDER BY folders.foldername, files.filename
|
||||
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.realname, 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 ?
|
||||
ORDER BY foldername
|
||||
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 = ?
|
||||
ORDER BY files.filename
|
||||
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 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 = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?;`
|
||||
|
||||
var insertFeedbackQuery = `INSERT INTO feedbacks (time, content, user_id, header)
|
||||
VALUES (?, ?, ?, ?);`
|
||||
|
||||
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 = ?;`
|
||||
|
||||
var insertUserQuery = `INSERT INTO users (username, password, role, active, avatar_id)
|
||||
VALUES (?, ?, ?, ?, ?);`
|
||||
|
||||
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 = ? 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 = ? LIMIT 1;`
|
||||
|
||||
var updateUserActiveQuery = `UPDATE users SET active = ? WHERE id = ?;`
|
||||
|
||||
var updateUsernameQuery = `UPDATE users SET username = ? WHERE id = ?;`
|
||||
|
||||
var updateUserPasswordQuery = `UPDATE users SET password = ? WHERE id = ?;`
|
||||
|
||||
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 (?, ?, ?);`
|
||||
|
||||
var deleteTagQuery = `DELETE FROM tags WHERE id = ?;`
|
||||
|
||||
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 = ? 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 = ?, description = ? WHERE id = ?;`
|
||||
|
||||
var putTagOnFileQuery = `INSERT OR IGNORE INTO file_has_tag (tag_id, file_id, user_id) VALUES (?, ?, ?);`
|
||||
|
||||
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 = ?
|
||||
ORDER BY tags.name
|
||||
;`
|
||||
|
||||
var deleteTagOnFileQuery = `DELETE FROM file_has_tag WHERE tag_id = ? AND file_id = ?;`
|
||||
|
||||
var deleteTagReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE tag_id = ?;`
|
||||
|
||||
var updateFoldernameQuery = `UPDATE folders SET foldername = ? WHERE id = ?;`
|
||||
|
||||
var insertReviewQuery = `INSERT INTO reviews (user_id, file_id, created_at, content)
|
||||
VALUES (?, ?, ?, ?);`
|
||||
|
||||
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 = ?
|
||||
ORDER BY reviews.created_at
|
||||
;`
|
||||
|
||||
var getReviewQuery = `SELECT id, file_id, user_id, created_at, updated_at, content FROM reviews WHERE id = ? LIMIT 1;`
|
||||
|
||||
var updateReviewQuery = `UPDATE reviews SET content = ?, updated_at = ? WHERE id = ?;`
|
||||
|
||||
var deleteReviewQuery = `DELETE FROM reviews WHERE id = ?;`
|
||||
|
||||
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 = ?
|
||||
ORDER BY reviews.created_at
|
||||
;`
|
||||
|
||||
var deleteFileQuery = `DELETE FROM files WHERE id = ?;`
|
||||
|
||||
var deleteFileReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE file_id = ?;`
|
||||
|
||||
var deleteFileReferenceInReviewsQuery = `DELETE FROM reviews WHERE file_id = ?;`
|
||||
|
||||
var updateFilenameQuery = `UPDATE files SET filename = ? WHERE id = ?;`
|
||||
|
||||
var resetFilenameQuery = `UPDATE files SET filename = realname WHERE id = ?;`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// init users table
|
||||
stmt.initUsersTable, err = sqlConn.Prepare(initUsersTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init avatars table
|
||||
stmt.initAvatarsTable, err = sqlConn.Prepare(initAvatarsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init tags table
|
||||
stmt.initTagsTable, err = sqlConn.Prepare(initTagsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init file_has_tag table
|
||||
stmt.initFileHasTag, err = sqlConn.Prepare(initFileHasTagTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init likes table
|
||||
stmt.initLikesTable, err = sqlConn.Prepare(initLikesTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init reviews table
|
||||
stmt.initReviewsTable, err = sqlConn.Prepare(initReviewsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init playbacks table
|
||||
stmt.initPlaybacksTable, err = sqlConn.Prepare(initPlaybacksTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init logs table
|
||||
stmt.initLogsTable, err = sqlConn.Prepare(initLogsTableQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init tmpfs table
|
||||
stmt.initTmpfsTable, err = sqlConn.Prepare(initTmpfsTableQuery)
|
||||
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
|
||||
}
|
||||
_, err = stmt.initUsersTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initAvatarsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initTagsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initFileHasTag.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initLikesTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initReviewsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initPlaybacksTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initLogsTable.Exec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = stmt.initTmpfsTable.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 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
|
||||
}
|
||||
|
||||
return stmt, err
|
||||
}
|
||||
73
pkg/database/struct.go
Normal file
73
pkg/database/struct.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type Folder struct {
|
||||
Db *Database `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Folder string `json:"-"`
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
RoleAnonymous = int64(0)
|
||||
RoleAdmin = int64(1)
|
||||
RoleUser = int64(2)
|
||||
)
|
||||
|
||||
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.Realname), 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
|
||||
}
|
||||
70
web/README.md
Normal file
70
web/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
3
web/axios.min.js
vendored
3
web/axios.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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')
|
||||
37505
web/package-lock.json
generated
Normal file
37505
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
web/package.json
Normal file
44
web/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "msw-open-music-react",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.15.0",
|
||||
"@testing-library/react": "^11.2.7",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "^4.0.3",
|
||||
"water.css": "^2.1.1",
|
||||
"web-vitals": "^1.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^17.0.34"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
41
web/public/index.html
Normal file
41
web/public/index.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!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" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<!-- Add to homescreen for Chrome on Android -->
|
||||
<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>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</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
|
||||
}
|
||||
113
web/src/App.css
Normal file
113
web/src/App.css
Normal file
@@ -0,0 +1,113 @@
|
||||
html {
|
||||
font-size: 1em;
|
||||
}
|
||||
body {
|
||||
margin: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.base {
|
||||
display: grid;
|
||||
grid-row-gap: 1em;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
155
web/src/App.js
Normal file
155
web/src/App.js
Normal file
@@ -0,0 +1,155 @@
|
||||
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");
|
||||
|
||||
// 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();
|
||||
});
|
||||
196
web/src/component/AudioPlayer.js
Normal file
196
web/src/component/AudioPlayer.js
Normal file
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// no playing file
|
||||
if (props.playingFile.id === undefined) {
|
||||
setPlayingURL("");
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [props.playingFile.id, 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>
|
||||
|
||||
{playingURL !== "" && (
|
||||
<audio
|
||||
id="dom-player"
|
||||
controls
|
||||
autoPlay
|
||||
loop={loop}
|
||||
className="audio-player"
|
||||
src={playingURL}
|
||||
></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";
|
||||
}
|
||||
114
web/src/component/Database.js
Normal file
114
web/src/component/Database.js
Normal file
@@ -0,0 +1,114 @@
|
||||
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"
|
||||
);
|
||||
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;
|
||||
101
web/src/component/EditReview.js
Normal file
101
web/src/component/EditReview.js
Normal file
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
142
web/src/component/EditTag.js
Normal file
142
web/src/component/EditTag.js
Normal file
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
106
web/src/component/FeedbackPage.js
Normal file
106
web/src/component/FeedbackPage.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
41
web/src/component/FfmpegConfig.js
Normal file
41
web/src/component/FfmpegConfig.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
57
web/src/component/FileDialog.js
Normal file
57
web/src/component/FileDialog.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
45
web/src/component/FileEntry.js
Normal file
45
web/src/component/FileEntry.js
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
308
web/src/component/FileInfo.js
Normal file
308
web/src/component/FileInfo.js
Normal file
@@ -0,0 +1,308 @@
|
||||
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);
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileInfo;
|
||||
137
web/src/component/FilesInFolder.js
Normal file
137
web/src/component/FilesInFolder.js
Normal file
@@ -0,0 +1,137 @@
|
||||
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 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);
|
||||
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} />
|
||||
<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;
|
||||
30
web/src/component/FilesTable.js
Normal file
30
web/src/component/FilesTable.js
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
36
web/src/component/FoldersTable.js
Normal file
36
web/src/component/FoldersTable.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
111
web/src/component/GetRandomFiles.js
Normal file
111
web/src/component/GetRandomFiles.js
Normal file
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
|
||||
function getRandomFiles() {
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/get_random_files")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setFiles(data.files);
|
||||
})
|
||||
.catch((error) => {
|
||||
alert("get_random_files error: " + error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomFilesWithTag() {
|
||||
setIsLoading(true);
|
||||
fetch("/api/v1/get_random_files_with_tag", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: parseInt(selectedTag),
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setFiles(data.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;
|
||||
74
web/src/component/Login.js
Normal file
74
web/src/component/Login.js
Normal file
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
100
web/src/component/Manage.js
Normal file
100
web/src/component/Manage.js
Normal file
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
81
web/src/component/ManageUser.js
Normal file
81
web/src/component/ManageUser.js
Normal file
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
82
web/src/component/Register.js
Normal file
82
web/src/component/Register.js
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
33
web/src/component/ReviewEntry.js
Normal file
33
web/src/component/ReviewEntry.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
76
web/src/component/ReviewPage.js
Normal file
76
web/src/component/ReviewPage.js
Normal file
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
93
web/src/component/SearchFiles.js
Normal file
93
web/src/component/SearchFiles.js
Normal file
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
91
web/src/component/SearchFolders.js
Normal file
91
web/src/component/SearchFolders.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
58
web/src/component/Share.js
Normal file
58
web/src/component/Share.js
Normal file
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
106
web/src/component/Tags.js
Normal file
106
web/src/component/Tags.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
170
web/src/component/UserProfile.js
Normal file
170
web/src/component/UserProfile.js
Normal file
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
16
web/src/component/UserStatus.js
Normal file
16
web/src/component/UserStatus.js
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
18
web/src/index.js
Normal file
18
web/src/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import 'water.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
13
web/src/reportWebVitals.js
Normal file
13
web/src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
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';
|
||||
44
web/src/translate/index.js
Normal file
44
web/src/translate/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createContext, renderToString } 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;
|
||||
@@ -1,74 +0,0 @@
|
||||
html {
|
||||
font-size: 1em;
|
||||
}
|
||||
.base {
|
||||
display: grid;
|
||||
grid-row-gap: 1em;
|
||||
}
|
||||
.header {
|
||||
color: white;
|
||||
background-color: rgb(63, 81, 181);
|
||||
box-shadow: 0 0 8px #393939;
|
||||
}
|
||||
.title {
|
||||
margin-left: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.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.router-link-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;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
3375
web/vue-router.js
3375
web/vue-router.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user