181 Commits

Author SHA1 Message Date
3e31f8822e typo
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2022-12-05 01:32:27 +08:00
6cff4247a8 typo
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2022-12-05 01:26:12 +08:00
90ff1382a8 add drone pipeline
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2022-12-05 01:24:56 +08:00
1450357b91 add drone pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2022-12-05 01:01:06 +08:00
7a31c36c10 Replace react with preact
reduce js file bundle to 20%
2022-12-04 20:08:08 +08:00
5271c12525 Replace webpack with only esbuild
reduce node_modules size to only 18M
2022-12-04 19:34:13 +08:00
d278e4009d fix avatar max 1 frame 2022-12-04 13:55:52 +08:00
e3d80ffc2a Fix: web height 100vh 2022-12-02 03:43:14 +08:00
30a8fbad4e Fix: web width 100% 2022-12-02 03:18:37 +08:00
e7bc625b6d Add: get alternative avatar from directory 2022-12-02 03:09:09 +08:00
2d71e7b0cb Update full avatar background 2022-12-01 23:47:07 +08:00
2297f87fa3 Update npm dependencies to react 18 2022-12-01 23:16:23 +08:00
271c7e5c13 Add: get avatar from file 2022-12-01 23:07:47 +08:00
9fdea6b169 Merge remote-tracking branch 'postgres' into postgres 2022-11-06 11:34:03 +08:00
38e20044e2 frontend treat 85% of duration as finished 2022-11-06 11:33:57 +08:00
830cbae17a log /v1/record_playback 2022-11-06 11:33:15 +08:00
51fee5bfe0 fix: break on browser don't support mediaSession
such as stupid WeChat Browser
2022-11-05 22:51:45 +08:00
e40fd2625f fix web playback log behavior 2022-11-05 01:24:40 +08:00
061ef9bdc9 improve web playback log behavior 2022-11-04 23:50:38 +08:00
d478923ce0 frontend record more types of playback 2022-11-04 22:12:15 +08:00
e4032069a5 fix postgres insert on conflict not returning id 2022-11-04 21:27:46 +08:00
73da4f8dc5 fix postgres unique constraint 2022-11-04 20:17:46 +08:00
89ff2bf452 fix postgres ILIKE 2022-11-04 20:06:00 +08:00
d0f6d19a7e frontend support record playback history 2022-11-04 19:06:06 +08:00
977b3e02e9 backend support record playback history 2022-11-04 18:39:24 +08:00
a6d82c1f47 migrate to postgres 2022-11-04 17:42:46 +08:00
b808d4be99 wrap walk sql into transaction improve performance 2022-11-04 15:55:13 +08:00
5c3fb66db3 change /v1/get_file use URL params 2022-11-03 08:18:07 +08:00
df081d39ca Update npm dependencies 2022-11-03 01:33:54 +08:00
2f2254371b web show folderPath in folder page 2022-11-03 01:21:58 +08:00
857a5e9dd9 support /v1/get_files_in_folder return folder path 2022-11-03 01:21:25 +08:00
2b4bbdf25e web support api /v1/get_file_ffprobe_info 2022-11-03 00:58:40 +08:00
08a5650b30 add api /v1/get_file_ffprobe_info 2022-11-03 00:58:33 +08:00
8a9569ea61 fix: web mediaSession react hook dependencies 2022-09-29 10:41:10 +08:00
dc380590e7 Revert "Revert "add basic support for mediaSession""
This reverts commit e5fa4c2b65.
2022-09-29 10:40:56 +08:00
e5fa4c2b65 Revert "add basic support for mediaSession"
This reverts commit edd5eeb4c0.
It break prepare mode on Android devices.
2022-09-29 10:28:34 +08:00
97693d6bd0 Change getRandomFiles function to async 2022-09-22 15:27:41 +08:00
edd5eeb4c0 add basic support for mediaSession 2022-09-21 16:15:51 +08:00
adf0c24f91 always render AudioPlayer DOM 2022-09-21 15:12:12 +08:00
539fbb6501 add webm as default index file type 2022-09-21 15:08:58 +08:00
f5dec2a0a7 add support change title on share page 2022-07-23 22:33:45 +08:00
da59740b47 fix Makefile phony 2022-07-23 09:49:40 +08:00
cae07f55cd add Github repo link 2022-07-23 09:24:53 +08:00
84cf09e61b add demo msw-open-music.live 2022-07-22 23:51:12 +08:00
36c1990e5e add README-cn.md 2022-07-22 23:45:38 +08:00
21e51756f0 update package.json version 2022-07-22 21:01:58 +08:00
caf8b47ca0 fix dialog and <button disabled> coincide 2022-07-22 20:59:06 +08:00
51e5f2d0fb add permission control 2022-07-22 20:56:36 +08:00
b0280767cb clean unused docs 2022-07-22 20:54:41 +08:00
85f25a38ae fix select default language 2022-07-22 16:49:30 +08:00
00399785d4 fix tranlate 2022-07-22 16:24:04 +08:00
f3e69b032f Merge branch 'dbms' 2022-07-22 15:32:41 +08:00
824866bdd8 bump go-sqlite3 and go.mod version 2022-07-22 15:31:30 +08:00
0b76ea08cd update README.md 2022-07-22 15:31:30 +08:00
ba1e96db26 add multi language support 2022-07-22 15:31:14 +08:00
cb5c752f8f add github CI build.yml 2022-07-21 17:31:38 +08:00
ff85724982 change bcrypt to MinCost 2022-07-20 19:43:38 +08:00
ad388cf83b format code with gofmt 2022-07-20 18:16:44 +08:00
881334cccb add password encrypt with bcrytp 2022-07-20 18:16:05 +08:00
edc42248ee fix backend output format 2022-06-12 18:01:30 +08:00
d7b6b3849c fix user profile button disabled status 2022-06-12 18:00:58 +08:00
4fcd962cc9 read secret key from config.json 2022-06-12 17:17:57 +08:00
c7382a1561 remove token auth method 2022-06-12 17:17:15 +08:00
4199caa5ef create tmpfs root directory if not exists 2022-06-12 16:46:19 +08:00
1cf8df7524 delete 'vodeo test' config 2022-06-12 16:39:31 +08:00
9ea21fa7f6 fix config.json 8k 2022-06-12 16:38:57 +08:00
8859640411 fix handle delete tmpfs file failed 2022-06-12 16:36:08 +08:00
d07df60b5d set default database index file pattern 2022-06-12 16:23:58 +08:00
02e5a39814 add routes for getRandomFiles
"t": string, the tagID, empty string stand for all tags
2022-06-12 16:14:47 +08:00
522844a447 update npm browserslist 2022-06-12 16:08:19 +08:00
32521e1178 add routes for FilesInFolder
"o": string, offset of the result
2022-06-12 16:06:02 +08:00
9f4c606b28 fix route get search files initial filename 2022-06-12 16:02:20 +08:00
58bb37fede add route for search folders
"q": string, foldername to search
"o": int, offset of search result
2022-06-12 16:00:28 +08:00
ff9774b806 move useQuery() to Common.js 2022-06-12 15:52:13 +08:00
7cb1a5d02f add frontend route for search files
"q": string, filename to search
"o": int, offset of search result
2022-06-12 02:48:17 +08:00
a9c2c2d7f9 fix access undefined when no files in folder 2022-06-12 00:24:28 +08:00
212ab56722 format 2022-06-12 00:23:53 +08:00
c2269ac0fc fix reset offset after search button clicked 2022-06-12 00:19:52 +08:00
14e9ff5a95 add: click filename in dialog to play 2022-06-12 00:11:39 +08:00
544b5afc0d Fix: add filename when download file 2022-04-18 02:32:42 +08:00
6e9e7252b2 Revert "Add: support video"
This reverts commit 465517e5cc.
2022-04-18 01:33:02 +08:00
61d85bba97 Add: support customized ffmpeg container format 2022-04-18 01:32:35 +08:00
e4c59fd539 Format: config.json 2022-04-17 22:20:49 +08:00
25205c0c0d Update npm dependencies 2022-04-17 21:55:34 +08:00
1655962e85 Merge tag 'v1.1.0' into dbms 2022-01-23 23:56:17 +08:00
2d85244ced Update: Makefile for external static lib 2022-01-22 11:40:41 +08:00
0897fcc9f8 Fix: more file pattern at update 2022-01-22 10:45:33 +08:00
2dab5cd109 Revert "Update: README.md for dbms" flow chart
This reverts commit a367b9253e.
2022-01-15 01:09:12 +08:00
a84dfe8178 Add: sleep stop timer 2022-01-15 00:53:42 +08:00
a367b9253e Update: README.md for dbms 2022-01-03 17:19:16 +08:00
b295707a05 Update: README.md for dbms 2022-01-03 17:19:16 +08:00
82da1aa48b Revert "Add: README.md DBMS TODO"
This reverts commit 2358335d4e.
2022-01-03 17:19:16 +08:00
359416ea44 Add: support reset foldername 2022-01-03 17:19:15 +08:00
5608693c06 Add: register button on manage page 2022-01-03 17:19:05 +08:00
d6f9a03786 SQL ORDER and IGNORE tag exists 2021-12-16 22:34:18 +08:00
7188e73783 Add: link to tag on fileinfo page 2021-12-16 22:33:32 +08:00
027aef4070 Move: play button after info button in dialog 2021-12-16 14:50:38 +08:00
14f3c1c8da Move: download button from dialog to file page 2021-12-16 13:27:11 +08:00
80802f95f8 Add: handle enter press 2021-12-16 13:22:18 +08:00
214ad6c285 Add: support reset filename 2021-12-16 13:22:18 +08:00
64d1e3ff78 Show detailed compile information 2021-12-16 13:22:17 +08:00
297643ad91 Change: manage page Edit to Profile 2021-12-16 13:22:17 +08:00
7efde3cf6f Add: support change filename
Fix: path method use realname
2021-12-16 13:22:10 +08:00
b0e57099ba Fix: manage page button horizontal 2021-12-16 12:04:15 +08:00
435e3605f7 Add: support delete file 2021-12-16 12:03:05 +08:00
0edc7f7141 Add: show file in ReviewEntry 2021-12-16 10:59:12 +08:00
6fdd0d2a9e Fix: insert or replace file_has_tag when it exists 2021-12-16 01:53:46 +08:00
465517e5cc Add: support video (still in test), change ogg to webm 2021-12-16 01:34:34 +08:00
fc735c88d3 Fix: center Login and Manage page 2021-12-15 16:23:40 +08:00
82c198d45b Reject anonymous user for some action 2021-12-15 11:34:01 +08:00
73828c547c Add: support config single thread 2021-12-15 09:11:03 +08:00
97083114fb Add: singleThreadLock for sqlite performance, and change Db.Tag method 2021-12-15 02:53:41 +08:00
1c14997b85 Change: title text to white 2021-12-14 10:43:39 +08:00
d59e40c6fa Fix: play another file while current file is preparing 2021-12-14 01:30:28 +08:00
47b178ac90 Add: tmpfs lock and fix bug 2021-12-14 01:19:50 +08:00
922b2370ee Update: review entry edit button to Link 2021-12-14 00:47:46 +08:00
83ab1a91b2 Add: support delete tag and its references 2021-12-14 00:33:31 +08:00
4bfcf460c9 Add: support show modified review time 2021-12-13 23:32:29 +08:00
28127f6138 Add: support delete feedback 2021-12-13 23:23:57 +08:00
0c9048072f Add: support feedback 2021-12-13 23:18:46 +08:00
22f7ea8476 Update: title space evenly 2021-12-13 22:27:01 +08:00
e1d9eac514 Add: updating database.. 2021-12-13 22:21:24 +08:00
1b0688e523 Add: User can change their password 2021-12-13 16:18:02 +08:00
f1e8dcfad4 Add: handle not active user 2021-12-13 14:28:24 +08:00
ab67575976 Add: set user acitve 2021-12-13 14:20:36 +08:00
adee9bcb65 Simplify: register not return user object 2021-12-13 13:47:02 +08:00
d7ca68aad1 Add: support insert active user 2021-12-13 13:43:09 +08:00
a826e4bf29 Add: support walk database with tags 2021-12-13 13:24:25 +08:00
d4718ac120 Add: support select ramdom files by tag 2021-12-13 07:07:43 +08:00
7a10922ec4 Add: link to user on tags page 2021-12-13 06:23:32 +08:00
164dd0f282 Add: show reviews created by user 2021-12-13 06:18:14 +08:00
f32c922faf Add: delete review 2021-12-13 05:52:10 +08:00
80462efebc Add: modify review 2021-12-13 05:27:12 +08:00
12739be2f5 Add: get reviews and fix bug 2021-12-13 04:47:00 +08:00
6b8bfedb9b Add: insert review 2021-12-13 04:17:00 +08:00
e87b4823d9 Add: update foldername 2021-12-13 03:43:09 +08:00
a2cb098330 Fix: bug login as anonymouse user null pointer 2021-12-12 18:28:47 +08:00
93a0fe7a31 Add: delete tag on file 2021-12-12 17:39:08 +08:00
003f8cace2 Fix: empty filename search 2021-12-12 17:23:27 +08:00
2c802ca807 Add: put tag on file 2021-12-12 17:19:14 +08:00
f71544caab Add: File info page 2021-12-12 16:21:43 +08:00
dfc0b43bdd Finished: tag 2021-12-12 15:41:33 +08:00
5a68cea2f3 Add: tag created_by_user_id column 2021-12-12 15:16:02 +08:00
047f15426b Fix: Handel error of checkAdmin 2021-12-12 15:08:44 +08:00
d2c852d57a Fix: Add tag only by admin 2021-12-12 13:03:36 +08:00
af444f0bbb Add: update tag info 2021-12-12 13:02:10 +08:00
1bbcecfb2e Add: simple get tags and create tag 2021-12-12 03:23:21 +08:00
b96daa07c6 Change: Update Database auth to user method 2021-12-12 01:57:54 +08:00
1f960f8f64 Add: Handle logout 2021-12-12 01:26:46 +08:00
e608a6b1df Add: backend session support and bug fix 2021-12-12 01:14:42 +08:00
f3a95973e9 Add: Simple user login/register function 2021-12-11 18:47:25 +08:00
c580ca245f Merge branch 'master' into dbms 2021-12-11 00:46:38 +08:00
9b4c0b24ef Update: README.md RESTful API 2021-12-11 00:37:43 +08:00
c418634515 Change: RESTful API 2021-12-11 00:22:41 +08:00
87ac0cecb7 Add: FilesInFolder page 2021-12-11 00:19:53 +08:00
1d49689171 Hide files/folders table if not need 2021-12-11 00:03:07 +08:00
fdd41397bf Update: skip file when exists in database 2021-12-10 20:46:26 +08:00
7e5c92dd63 Add: simple update database function 2021-12-10 15:17:51 +08:00
83f2b76cbc Re-struct pkg/api, pkg/database 2021-12-10 15:17:50 +08:00
05a569e395 Fix: feedbacks's column header 2021-12-09 11:21:23 +08:00
e961c10d4e Add: ER Diagram (draw.io) 2021-12-07 23:23:43 +08:00
2d7ac69db5 Update: init SQL feedbacks tags tmpfs 2021-12-07 14:15:46 +08:00
47a60ae671 Fix: SQL init user avatar 2021-12-07 10:42:18 +08:00
ca8b6cb893 Add SQL create table statement 2021-12-07 10:26:50 +08:00
258bf9869f Re-struct pkg/api, pkg/database 2021-12-07 00:25:18 +08:00
be2515231c Update: docs/problem_description add extra description 2021-11-25 23:57:54 +08:00
2358335d4e Add: README.md DBMS TODO 2021-11-25 23:57:42 +08:00
1bef4d0272 Add: docs/problem_description.md 2021-11-25 23:55:43 +08:00
546385a484 Update: Share instruction 2021-11-25 23:48:09 +08:00
d8470d0f4b Update: FileDialog download method 2021-11-25 23:48:09 +08:00
85a6c2b859 fix bug recursive FileDialog 2021-11-25 23:48:06 +08:00
057e21285b Change README.md to english 2021-11-25 16:12:55 +08:00
e485d1a8c5 Update: documents and Makefile 2021-11-24 11:39:40 +08:00
e3de41fe07 add web handle fetch error 2021-11-24 11:09:21 +08:00
8a2c8dd8b2 add coc .prettierrc.json for web 2021-11-24 11:08:40 +08:00
abc0096ade fix: web package version number 2021-11-24 10:48:43 +08:00
e170c8b842 Rewrite web front-end using React
This is a big commit, some font-end function are still working,
including manage, error handle, as others.
2021-11-21 17:30:43 +08:00
d556bbe0c8 Break: change ffmpeg_configs to ffmpeg_config_list structure 2021-11-14 23:18:09 +08:00
b1fb8b0866 Update README.md for release v1.0.0 2021-05-30 11:14:56 +08:00
b0d903a096 delete test.html 2021-05-30 11:11:55 +08:00
3c6552e480 Merge branch 'no-dialog' 2021-05-30 02:53:04 +08:00
108 changed files with 8511 additions and 21524 deletions

18
.drone.yml Normal file
View File

@@ -0,0 +1,18 @@
kind: pipeline
type: docker
name: default
steps:
- name: frontend-web
image: node:19
commands:
- cd web
- npm install
- npm run build
- name: release
image: plugins/gitea-release
settings:
api_key: da966507c259aa32ccc2d434e930af4a580de785
base_url: https://yongyuancv.cn/git/
files: build/*

68
.github/workflows/build.yml vendored Normal file
View 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

View File

@@ -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
View File

@@ -0,0 +1,137 @@
# MSW Open Music Project
[![CI](https://github.com/heimoshuiyu/msw-open-music/actions/workflows/build.yml/badge.svg)](https://github.com/heimoshuiyu/msw-open-music/actions/workflows/build.yml)
🔴 演示 Demo: <https://msw-open-music.live>
> 找一首歌最好的方法是:打开一个超长的歌单,然后随机播放,直到你找到为止。
一个 💪 轻量级 ⚡️ 高性能 🖥️ 跨平台的 个人音乐串流平台。管理你现有的音乐文件并在其他设备上播放。
前端网页应用基于 `react.js``water.css` 构建。后端服务器程序使用 `golang``sqlite` 构建。
## 介绍
截图
![demo1](demo1.jpg)
### 功能特点
- 🔎 索引现有的音乐文件,并记录文件名和文件夹元信息
- 📕 使用 文件夹 📁 标签 🏷️ 评论 💬 来管理你的音乐。
- 🌐 提供一个轻量高效的网页前端并支持多种语言。
- 👥 支持多用户。
- 🔥 调用 `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
![ER Diagram](erdiagram.png)
- `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 和 数据库对象 的 数据结构。

420
README.md
View File

@@ -1,62 +1,116 @@
# MSW Open Music Project
## 简介
[![CI](https://github.com/heimoshuiyu/msw-open-music/actions/workflows/build.yml/badge.svg)](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
![demo1](demo1.jpg)
[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
## 后端 API 文档
- `secret` string type. Secret to encrypt the session.
说明中带有 `stream` `流` 相关字样的,说明该 API 以 `io.Copy` 方式传输文件,不支持断点续传
- `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 将返回 OK某些 API 可能会在 `status` 字段中返回详细的执行信息。
For windows user, make sure you have `ffmpeg` installed.
## Development
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
![ER Diagram](erdiagram.png)
- `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
{
@@ -64,302 +118,22 @@ Fork from `msw-file`,目前是一个音乐播放器。
}
```
### 公开 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.

View File

@@ -1,18 +1,75 @@
{
"api": {
"database_name": "music.sqlite3",
"secret": "CHANGE_YOUR_SECRET_HERE",
"database_name": "postgres://postgres:woshimima@localhost/postgres?sslmode=disable",
"single_thread": true,
"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"}
"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": {

BIN
demo1.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 46 KiB

1
docs/ER Diagram.drawio Normal file

File diff suppressed because one or more lines are too long

BIN
erdiagram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

10
go.mod
View File

@@ -1,5 +1,11 @@
module msw-open-music
go 1.16
go 1.18
require github.com/mattn/go-sqlite3 v1.14.7 // indirect
require (
github.com/gorilla/sessions v1.2.1
github.com/lib/pq v1.10.7
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
)
require github.com/gorilla/securecookie v1.1.1 // indirect

10
go.sum
View File

@@ -1,2 +1,8 @@
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View File

@@ -4,8 +4,8 @@ import (
"encoding/json"
"flag"
"log"
"msw-open-music/internal/pkg/api"
"msw-open-music/internal/pkg/tmpfs"
"msw-open-music/pkg/api"
"msw-open-music/pkg/commonconfig"
"os"
)
@@ -15,16 +15,11 @@ func init() {
flag.StringVar(&ConfigFilePath, "config", "config.json", "backend config file path")
}
type Config struct {
APIConfig api.APIConfig `json:"api"`
TmpfsConfig tmpfs.TmpfsConfig `json:"tmpfs"`
}
func main() {
var err error
flag.Parse()
config := Config{}
config := commonconfig.Config{}
configFile, err := os.Open(ConfigFilePath)
if err != nil {
log.Fatal(err)
@@ -35,14 +30,13 @@ func main() {
}
configFile.Close()
api, err := api.NewAPI(config.APIConfig, config.TmpfsConfig)
api, err := api.NewAPI(config)
if err != nil {
log.Fatal(err)
}
log.Println("Starting",
config.APIConfig.DatabaseName,
config.APIConfig.Addr,
config.APIConfig.Token,
)
log.Fatal(api.Server.ListenAndServe())
}

109
pkg/api/api.go Normal file
View File

@@ -0,0 +1,109 @@
package api
import (
"github.com/gorilla/sessions"
"msw-open-music/pkg/commonconfig"
"msw-open-music/pkg/database"
"msw-open-music/pkg/tmpfs"
"net/http"
)
type API struct {
Db *database.Database
Server http.Server
APIConfig commonconfig.APIConfig
Tmpfs *tmpfs.Tmpfs
store *sessions.CookieStore
defaultSessionName string
}
func NewAPI(config commonconfig.Config) (*API, error) {
var err error
apiConfig := config.APIConfig
tmpfsConfig := config.TmpfsConfig
db, err := database.NewDatabase(apiConfig.DatabaseName, apiConfig.SingleThread)
if err != nil {
return nil, err
}
store := sessions.NewCookieStore([]byte(config.APIConfig.SECRET))
mux := http.NewServeMux()
apiMux := http.NewServeMux()
api := &API{
Db: db,
Server: http.Server{
Addr: apiConfig.Addr,
Handler: mux,
},
APIConfig: apiConfig,
store: store,
defaultSessionName: "msw-open-music",
}
api.Tmpfs = tmpfs.NewTmpfs(tmpfsConfig)
// mount api
apiMux.HandleFunc("/hello", api.HandleOK)
apiMux.HandleFunc("/get_file", api.HandleGetFile)
apiMux.HandleFunc("/get_file_direct", api.HandleGetFileDirect)
apiMux.HandleFunc("/search_files", api.HandleSearchFiles)
apiMux.HandleFunc("/search_folders", api.HandleSearchFolders)
apiMux.HandleFunc("/get_files_in_folder", api.HandleGetFilesInFolder)
apiMux.HandleFunc("/get_random_files", api.HandleGetRandomFiles)
apiMux.HandleFunc("/get_random_files_with_tag", api.HandleGetRandomFilesWithTag)
apiMux.HandleFunc("/get_file_stream", api.HandleGetFileStream)
apiMux.HandleFunc("/get_ffmpeg_config_list", api.HandleGetFfmpegConfigs)
apiMux.HandleFunc("/get_file_info", api.HandleGetFileInfo)
apiMux.HandleFunc("/get_file_ffprobe_info", api.HandleGetFileFfprobeInfo)
apiMux.HandleFunc("/get_file_stream_direct", api.HandleGetFileStreamDirect)
apiMux.HandleFunc("/get_file_avatar", api.HandelGetFileAvatar)
apiMux.HandleFunc("/prepare_file_stream_direct", api.HandlePrepareFileStreamDirect)
apiMux.HandleFunc("/delete_file", api.HandleDeleteFile)
apiMux.HandleFunc("/update_filename", api.HandleUpdateFilename)
apiMux.HandleFunc("/reset_filename", api.HandleResetFilename)
apiMux.HandleFunc("/reset_foldername", api.HandleResetFoldername)
// feedback
apiMux.HandleFunc("/feedback", api.HandleFeedback)
apiMux.HandleFunc("/get_feedbacks", api.HandleGetFeedbacks)
apiMux.HandleFunc("/delete_feedback", api.HandleDeleteFeedback)
// user
apiMux.HandleFunc("/login", api.HandleLogin)
apiMux.HandleFunc("/register", api.HandleRegister)
apiMux.HandleFunc("/logout", api.HandleLoginAsAnonymous)
apiMux.HandleFunc("/get_user_info", api.HandleGetUserInfo)
apiMux.HandleFunc("/get_users", api.HandleGetUsers)
apiMux.HandleFunc("/update_user_active", api.HandleUpdateUserActive)
apiMux.HandleFunc("/update_username", api.HandleUpdateUsername)
apiMux.HandleFunc("/update_user_password", api.HandleUpdateUserPassword)
// tag
apiMux.HandleFunc("/get_tags", api.HandleGetTags)
apiMux.HandleFunc("/get_tag_info", api.HandleGetTagInfo)
apiMux.HandleFunc("/insert_tag", api.HandleInsertTag)
apiMux.HandleFunc("/update_tag", api.HandleUpdateTag)
apiMux.HandleFunc("/put_tag_on_file", api.HandlePutTagOnFile)
apiMux.HandleFunc("/get_tags_on_file", api.HandleGetTagsOnFile)
apiMux.HandleFunc("/delete_tag_on_file", api.HandleDeleteTagOnFile)
apiMux.HandleFunc("/delete_tag", api.HandleDeleteTag)
// folder
apiMux.HandleFunc("/update_foldername", api.HandleUpdateFoldername)
// review
apiMux.HandleFunc("/insert_review", api.HandleInsertReview)
apiMux.HandleFunc("/get_reviews_on_file", api.HandleGetReviewsOnFile)
apiMux.HandleFunc("/get_review", api.HandleGetReview)
apiMux.HandleFunc("/update_review", api.HandleUpdateReview)
apiMux.HandleFunc("/delete_review", api.HandleDeleteReview)
apiMux.HandleFunc("/get_reviews_by_user", api.HandleGetReviewsByUser)
// statistic
apiMux.HandleFunc("/record_playback", api.HandleRecordPlayback)
// database
apiMux.HandleFunc("/walk", api.HandleWalk)
apiMux.HandleFunc("/reset", api.HandleReset)
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api.PermissionMiddleware(apiMux)))
mux.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("web/build"))))
return api, nil
}

17
pkg/api/check.go Normal file
View File

@@ -0,0 +1,17 @@
package api
import (
"errors"
"log"
"net/http"
)
func (api *API) CheckLimit(w http.ResponseWriter, r *http.Request, limit int64) error {
if limit <= 0 || limit > 10 {
log.Println("[api] [Warning] Limit error", limit)
err := errors.New(`"limit" can't be zero or more than 10`)
api.HandleError(w, r, err)
return err
}
return nil
}

108
pkg/api/handle_avatar.go Normal file
View File

@@ -0,0 +1,108 @@
package api
import (
"bytes"
"errors"
"io"
"log"
"msw-open-music/pkg/database"
"net/http"
"os"
"os/exec"
"path"
"strconv"
"strings"
)
func (api *API) HandelGetFileAvatar(w http.ResponseWriter, r *http.Request) {
var err error
q := r.URL.Query()
ids := q["id"]
if len(ids) == 0 {
err = errors.New(`parameter "id" can't be empty`)
api.HandleError(w, r, err)
return
}
id, err := strconv.Atoi(ids[0])
if err != nil {
api.HandleError(w, r, err)
return
}
file, err := api.Db.GetFile(int64(id))
if err != nil {
api.HandleError(w, r, err)
return
}
path, err := file.Path()
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Get avatar of file", path)
buff := make([]byte, 0)
cache := bytes.NewBuffer(buff)
cmd := exec.Command("ffmpeg", "-i", path, "-c:v", "libwebp_anim", "-update", "1", "-frames:v", "1", "-f", "image2pipe", "-")
cmd.Stdout = cache
err = cmd.Run()
if err != nil {
api.HandleGetAlternativeFileAvatar(w, r, file)
return
}
w.Header().Set("Content-Type", "image/webp")
io.Copy(w, cache)
}
func (api *API) HandleGetAlternativeFileAvatar(w http.ResponseWriter, r *http.Request, f *database.File) {
var err error
dir, err := f.Dir()
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Get alternative avatar in dir", dir)
files, err := os.ReadDir(dir)
if err != nil {
api.HandleError(w, r, err)
return
}
avatar, err := findAvatarFile(files)
avatarPath := path.Join(dir, avatar)
if err != nil {
api.HandleError(w, r, err)
return
}
cmd := exec.Command("ffmpeg", "-i", avatarPath, "-c:v", "libwebp_anim", "-f", "image2pipe", "-")
cmd.Stdout = w
w.Header().Set("Content-Type", "image/webp")
err = cmd.Run()
if err != nil {
api.HandleError(w, r, err)
return
}
}
func findAvatarFile(files []os.DirEntry) (string, error) {
for _, file := range files {
if isAvatarType(file.Name()) {
return file.Name(), nil
}
}
return "", errors.New("Cannot find avatar file")
}
var avatarFileTypes = []string{
".jpg",
".png",
}
func isAvatarType(filename string) bool {
for _, t := range avatarFileTypes {
if strings.HasSuffix(strings.ToLower(filename), t) {
return true
}
}
return false
}

26
pkg/api/handle_common.go Normal file
View 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)
}

View 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
View 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)
}

View 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)
}

View 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)
}

View File

@@ -0,0 +1,165 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
)
type GetFileRequest struct {
ID int64 `json:"id"`
}
func (api *API) HandleGetFileInfo(w http.ResponseWriter, r *http.Request) {
getFileRequest := &GetFileRequest{
ID: -1,
}
err := json.NewDecoder(r.Body).Decode(getFileRequest)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empty
if getFileRequest.ID < 0 {
api.HandleErrorString(w, r, `"id" can't be none or negative`)
return
}
log.Println("[api] Get file info", getFileRequest.ID)
file, err := api.Db.GetFile(getFileRequest.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
err = json.NewEncoder(w).Encode(file)
if err != nil {
api.HandleError(w, r, err)
return
}
}
func (api *API) HandleGetFileFfprobeInfo(w http.ResponseWriter, r *http.Request) {
getFileRequest := &GetFileRequest {
ID: -1,
}
err := json.NewDecoder(r.Body).Decode(getFileRequest)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empty
if getFileRequest.ID < 0 {
api.HandleErrorString(w, r, `"id" can't be none or negative`)
return
}
log.Println("[api] Get file Ffprobe info", getFileRequest.ID)
file, err := api.Db.GetFile(getFileRequest.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
path, err := file.Path()
if err != nil {
api.HandleError(w, r, err)
return
}
cmd := exec.Command("ffprobe", "-i", path, "-hide_banner")
cmd.Stderr = w
err = cmd.Run()
if err != nil {
api.HandleError(w, r, err)
return
}
}
// /api/v1/get_file?id=123
// get raw file with io.Copy method
func (api *API) HandleGetFile(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ids := q["id"]
_id, err := strconv.Atoi(ids[0])
if err != nil {
api.HandleError(w, r, err)
return
}
id := int64(_id)
// check empty
if id < 0 {
api.HandleErrorString(w, r, `"id" can't be none or negative`)
return
}
file, err := api.Db.GetFile(id)
if err != nil {
api.HandleError(w, r, err)
return
}
path, err := file.Path()
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Get pipe raw file", path)
src, err := os.Open(path)
if err != nil {
api.HandleError(w, r, err)
return
}
defer src.Close()
io.Copy(w, src)
}
// /get_file_direct?id=1
// get raw file with http.ServeFile method
func (api *API) HandleGetFileDirect(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
ids := q["id"]
if len(ids) == 0 {
api.HandleErrorString(w, r, `parameter "id" can't be empty`)
return
}
id, err := strconv.Atoi(ids[0])
if err != nil {
api.HandleErrorString(w, r, `parameter "id" should be an integer`)
return
}
file, err := api.Db.GetFile(int64(id))
if err != nil {
api.HandleError(w, r, err)
return
}
path, err := file.Path()
if err != nil {
api.HandleError(w, r, err)
return
}
// set header for filename
filename := file.Filename
// encode filename to URL
filename = url.PathEscape(filename)
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+filename)
log.Println("[api] Get direct raw file", path)
http.ServeFile(w, r, path)
}

View File

@@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"log"
"msw-open-music/pkg/database"
"net/http"
)
type GetFilesInFolderRequest struct {
Folder_id int64 `json:"folder_id"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
}
type GetFilesInFolderResponse struct {
Files *[]database.File `json:"files"`
Folder string `json:"folder"`
}
func (api *API) HandleGetFilesInFolder(w http.ResponseWriter, r *http.Request) {
getFilesInFolderRequest := &GetFilesInFolderRequest{
Folder_id: -1,
}
err := json.NewDecoder(r.Body).Decode(getFilesInFolderRequest)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empyt
if getFilesInFolderRequest.Folder_id < 0 {
api.HandleErrorString(w, r, `"folder_id" can't be none or negative`)
return
}
files, folder, err := api.Db.GetFilesInFolder(getFilesInFolderRequest.Folder_id, getFilesInFolderRequest.Limit, getFilesInFolderRequest.Offset)
if err != nil {
api.HandleError(w, r, err)
return
}
getFilesInFolderResponse := &GetFilesInFolderResponse{
Files: &files,
Folder: folder,
}
log.Println("[api] Get files in folder", getFilesInFolderRequest.Folder_id)
json.NewEncoder(w).Encode(getFilesInFolderResponse)
}

View 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)
}

View 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)
}

View 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
View 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
}
}

View 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)
}

View File

@@ -0,0 +1,48 @@
package api
import (
"encoding/json"
"log"
"msw-open-music/pkg/database"
"net/http"
)
type SearchFoldersRequest struct {
Foldername string `json:"foldername"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
}
type SearchFoldersResponse struct {
Folders []database.Folder `json:"folders"`
}
func (api *API) HandleSearchFolders(w http.ResponseWriter, r *http.Request) {
searchFoldersRequest := &SearchFoldersRequest{}
err := json.NewDecoder(r.Body).Decode(searchFoldersRequest)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empty
if searchFoldersRequest.Foldername == "" {
api.HandleErrorString(w, r, `"foldername" can't be empty`)
return
}
if api.CheckLimit(w, r, searchFoldersRequest.Limit) != nil {
return
}
searchFoldersResponse := &SearchFoldersResponse{}
searchFoldersResponse.Folders, err = api.Db.SearchFolders(searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Search folders", searchFoldersRequest.Foldername, searchFoldersRequest.Limit, searchFoldersRequest.Offset)
json.NewEncoder(w).Encode(searchFoldersResponse)
}

51
pkg/api/handle_stat.go Normal file
View File

@@ -0,0 +1,51 @@
package api
import (
"encoding/json"
"log"
"msw-open-music/pkg/database"
"net/http"
"time"
)
type RecordPlaybackRequest struct {
Playback database.Playback `json:"playback"`
}
func (api *API) HandleRecordPlayback(w http.ResponseWriter, r *http.Request) {
recordPlaybackRequest := &RecordPlaybackRequest{}
err := json.NewDecoder(r.Body).Decode(recordPlaybackRequest)
if err != nil {
api.HandleError(w, r, err)
return
}
recordPlaybackRequest.Playback.Time = time.Now()
recordPlaybackRequest.Playback.UserID, err = api.GetUserID(w, r)
if err != nil {
if err == ErrNotLoggedIn {
user, err := api.Db.LoginAsAnonymous()
if err != nil {
api.HandleError(w, r, err)
return
}
recordPlaybackRequest.Playback.UserID = user.ID
} else {
api.HandleError(w, r, err)
return
}
}
log.Println("[api] Record playback history",
recordPlaybackRequest.Playback.UserID,
recordPlaybackRequest.Playback.FileID,
recordPlaybackRequest.Playback.Duration,
recordPlaybackRequest.Playback.Method)
err = api.Db.RecordPlayback(recordPlaybackRequest.Playback)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}

211
pkg/api/handle_stream.go Normal file
View 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
View 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)
}

View File

@@ -0,0 +1,105 @@
package api
import (
"encoding/json"
"log"
"msw-open-music/pkg/database"
"net/http"
)
type PutTagOnFileRequest struct {
TagID int64 `json:"tag_id"`
FileID int64 `json:"file_id"`
}
func (api *API) HandlePutTagOnFile(w http.ResponseWriter, r *http.Request) {
req := &PutTagOnFileRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
userID, err := api.GetUserID(w, r)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empty
if req.TagID == 0 || req.FileID == 0 {
api.HandleError(w, r, ErrEmpty)
return
}
log.Println("[api] Put tag on file request:", req, "userID:", userID)
api.Db.PutTagOnFile(req.TagID, req.FileID, userID)
api.HandleOK(w, r)
}
type GetTagsOnFileRequest struct {
ID int64 `json:"id"`
}
type GetTagsOnFileResponse struct {
Tags []*database.Tag `json:"tags"`
}
func (api *API) HandleGetTagsOnFile(w http.ResponseWriter, r *http.Request) {
req := &GetTagsOnFileRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Get tags on file request:", req)
tags, err := api.Db.GetTagsOnFile(req.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
resp := &GetTagsOnFileResponse{
Tags: tags,
}
err = json.NewEncoder(w).Encode(resp)
if err != nil {
api.HandleError(w, r, err)
return
}
}
type DeleteTagOnFileRequest struct {
TagID int64 `json:"tag_id"`
FileID int64 `json:"file_id"`
}
func (api *API) HandleDeleteTagOnFile(w http.ResponseWriter, r *http.Request) {
req := &DeleteTagOnFileRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
// check empty
if req.TagID == 0 || req.FileID == 0 {
api.HandleError(w, r, ErrEmpty)
return
}
log.Println("[api] Delete tag on file request:", req)
err = api.Db.DeleteTagOnFile(req.TagID, req.FileID)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}

326
pkg/api/handle_user.go Normal file
View File

@@ -0,0 +1,326 @@
package api
import (
"database/sql"
"encoding/json"
"log"
"msw-open-music/pkg/database"
"net/http"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
User *database.User `json:"user"`
}
func (api *API) HandleLoginAsAnonymous(w http.ResponseWriter, r *http.Request) {
user, err := api.LoginAsAnonymous(w, r)
resp := &LoginResponse{
User: user,
}
err = json.NewEncoder(w).Encode(resp)
if err != nil {
api.HandleError(w, r, err)
return
}
}
func (api *API) LoginAsAnonymous(w http.ResponseWriter, r *http.Request) (*database.User, error) {
user, err := api.Db.LoginAsAnonymous()
if err != nil {
return nil, err
}
session, _ := api.store.Get(r, api.defaultSessionName)
// save session
session.Values["userId"] = user.ID
err = session.Save(r, w)
if err != nil {
return nil, err
}
// return user
return user, nil
}
func (api *API) HandleLogin(w http.ResponseWriter, r *http.Request) {
var user *database.User
var err error
session, _ := api.store.Get(r, api.defaultSessionName)
// Get method will login current or anonymous user
if r.Method == "GET" {
// if user already logged in
if userId, ok := session.Values["userId"]; ok {
user, err = api.Db.GetUserById(userId.(int64))
if err != nil {
if err != sql.ErrNoRows {
api.HandleError(w, r, err)
return
}
log.Println("[api] Warning: User not found")
// login as anonymous user
api.LoginAsAnonymous(w, r)
return
}
log.Println("[api] User already logged in:", user)
} else {
// login as anonymous user
log.Println("[api] Login as anonymous user")
api.LoginAsAnonymous(w, r)
return
}
} else {
var request LoginRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("Login as user", request.Username)
user, err = api.Db.Login(request.Username, request.Password)
if err != nil {
api.HandleError(w, r, err)
return
}
}
// if user is not active
if !user.Active {
api.HandleError(w, r, ErrNotActive)
return
}
// save session
session.Values["userId"] = user.ID
err = session.Save(r, w)
if err != nil {
api.HandleError(w, r, err)
return
}
resp := &LoginResponse{
User: user,
}
err = json.NewEncoder(w).Encode(resp)
if err != nil {
api.HandleError(w, r, err)
return
}
}
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role int64 `json:"role"`
}
func (api *API) HandleRegister(w http.ResponseWriter, r *http.Request) {
var request RegisterRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
api.HandleError(w, r, err)
return
}
log.Println("[api] Register user", request.Username)
err = api.Db.Register(request.Username, request.Password, request.Role)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}
func (api *API) GetUserID(w http.ResponseWriter, r *http.Request) (int64, error) {
session, _ := api.store.Get(r, api.defaultSessionName)
userId, ok := session.Values["userId"]
if !ok {
return 0, ErrNotLoggedIn
}
return userId.(int64), nil
}
type GetUsersResponse struct {
Users []*database.User `json:"users"`
}
func (api *API) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := api.Db.GetUsers()
if err != nil {
api.HandleError(w, r, err)
return
}
ret := &GetUsersResponse{
Users: users,
}
err = json.NewEncoder(w).Encode(ret)
if err != nil {
api.HandleError(w, r, err)
return
}
}
type UpdateUserActiveRequest struct {
ID int64 `json:"id"`
Active bool `json:"active"`
}
func (api *API) HandleUpdateUserActive(w http.ResponseWriter, r *http.Request) {
req := &UpdateUserActiveRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
err = api.Db.UpdateUserActive(req.ID, req.Active)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}
type UpdateUsernameRequest struct {
ID int64 `json:"id"`
Username string `json:"username"`
}
func (api *API) HandleUpdateUsername(w http.ResponseWriter, r *http.Request) {
// middileware reject anonymous user
req := &UpdateUsernameRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
user, err := api.Db.GetUserById(req.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
userID, err := api.GetUserID(w, r)
if err != nil {
api.HandleError(w, r, err)
return
}
if user.ID != userID && user.Role != database.RoleAdmin {
api.HandleError(w, r, ErrNotAdmin)
return
}
err = api.Db.UpdateUsername(req.ID, req.Username)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}
type GetUserInfoRequest struct {
ID int64 `json:"id"`
}
type GetUserInfoResponse struct {
User *database.User `json:"user"`
}
func (api *API) HandleGetUserInfo(w http.ResponseWriter, r *http.Request) {
req := &GetUserInfoRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
user, err := api.Db.GetUserById(req.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
ret := &GetUserInfoResponse{
User: user,
}
err = json.NewEncoder(w).Encode(ret)
if err != nil {
api.HandleError(w, r, err)
return
}
}
type UpdateUserPasswordRequest struct {
ID int64 `json:"id"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
func (api *API) HandleUpdateUserPassword(w http.ResponseWriter, r *http.Request) {
// middleware reject anonymous user
req := &UpdateUserPasswordRequest{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
api.HandleError(w, r, err)
return
}
user, err := api.Db.GetUserById(req.ID)
if err != nil {
api.HandleError(w, r, err)
return
}
userID, err := api.GetUserID(w, r)
if err != nil {
api.HandleError(w, r, err)
return
}
currentUser, err := api.Db.GetUserById(userID)
if err != nil {
api.HandleError(w, r, err)
return
}
if currentUser.Role != database.RoleAdmin {
_, err := api.Db.Login(user.Username, req.OldPassword)
if err != nil {
api.HandleError(w, r, ErrWrongPassword)
return
}
}
err = api.Db.UpdateUserPassword(req.ID, req.NewPassword)
if err != nil {
api.HandleError(w, r, err)
return
}
api.HandleOK(w, r)
}

55
pkg/api/middleware.go Normal file
View 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
}

View 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
View File

@@ -0,0 +1,63 @@
package database
import (
"database/sql"
"sync"
_ "github.com/lib/pq"
)
type Database struct {
sqlConn *sql.DB
stmt *Stmt
singleThreadLock SingleThreadLock
}
func NewSingleThreadLock(enabled bool) SingleThreadLock {
return SingleThreadLock{
lock: sync.Mutex{},
enabled: enabled,
}
}
type SingleThreadLock struct {
lock sync.Mutex
enabled bool
}
func (stl *SingleThreadLock) Lock() {
if stl.enabled {
stl.lock.Lock()
}
}
func (stl *SingleThreadLock) Unlock() {
if stl.enabled {
stl.lock.Unlock()
}
}
func NewDatabase(dbName string, singleThread bool) (*Database, error) {
var err error
// open database
sqlConn, err := sql.Open("postgres", dbName)
if err != nil {
return nil, err
}
// prepare statement
stmt, err := NewPreparedStatement(sqlConn)
if err != nil {
return nil, err
}
// new database
database := &Database{
sqlConn: sqlConn,
stmt: stmt,
singleThreadLock: NewSingleThreadLock(singleThread),
}
return database, nil
}

10
pkg/database/error.go Normal file
View File

@@ -0,0 +1,10 @@
package database
import (
"errors"
)
var (
ErrNotFound = errors.New("object not found")
ErrTagNotFound = errors.New("tag not found")
)

456
pkg/database/method.go Normal file
View File

@@ -0,0 +1,456 @@
package database
import (
"errors"
"log"
"os"
"path/filepath"
)
func (database *Database) GetRandomFiles(limit int64) ([]File, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getRandomFiles.Query(limit)
if err != nil {
return nil, err
}
defer rows.Close()
files := make([]File, 0)
for rows.Next() {
file := File{
Db: database,
}
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
if err != nil {
return nil, err
}
files = append(files, file)
}
return files, nil
}
func (database *Database) GetRandomFilesWithTag(tagID, limit int64) ([]File, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getRandomFilesWithTag.Query(tagID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
files := make([]File, 0)
for rows.Next() {
file := File{
Db: database,
}
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
if err != nil {
return nil, err
}
files = append(files, file)
}
return files, nil
}
func (database *Database) GetFilesInFolder(folder_id int64, limit int64, offset int64) ([]File, string, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getFilesInFolder.Query(folder_id, limit, offset)
if err != nil {
return nil, "", err
}
defer rows.Close()
files := make([]File, 0)
folder := ""
for rows.Next() {
file := File{
Db: database,
Folder_id: folder_id,
}
err = rows.Scan(&file.ID, &file.Filename, &file.Filesize, &file.Foldername, &folder)
if err != nil {
return nil, "", err
}
files = append(files, file)
}
return files, folder, nil
}
func (database *Database) SearchFolders(foldername string, limit int64, offset int64) ([]Folder, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.searchFolders.Query("%"+foldername+"%", limit, offset)
if err != nil {
return nil, errors.New("Error searching folders at query " + err.Error())
}
defer rows.Close()
folders := make([]Folder, 0)
for rows.Next() {
folder := Folder{
Db: database,
}
err = rows.Scan(&folder.ID, &folder.Folder, &folder.Foldername)
if err != nil {
return nil, errors.New("Error scanning SearchFolders" + err.Error())
}
folders = append(folders, folder)
}
return folders, nil
}
func (database *Database) GetFile(id int64) (*File, error) {
file := &File{
Db: database,
}
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
err := database.stmt.getFile.QueryRow(id).Scan(&file.ID, &file.Folder_id, &file.Realname, &file.Filename, &file.Foldername, &file.Filesize)
if err != nil {
return nil, err
}
return file, nil
}
func (database *Database) ResetFiles() error {
log.Println("[db] Reset files")
var err error
_, err = database.stmt.dropFiles.Exec()
if err != nil {
return err
}
_, err = database.stmt.initFilesTable.Exec()
if err != nil {
return err
}
return err
}
func (database *Database) ResetFolder() error {
log.Println("[db] Reset folders")
var err error
_, err = database.stmt.dropFolder.Exec()
if err != nil {
return err
}
_, err = database.stmt.initFoldersTable.Exec()
if err != nil {
return err
}
return err
}
func (database *Database) Walk(root string, pattern []string, tagIDs []int64, userID int64) error {
patternDict := make(map[string]bool)
for _, v := range pattern {
patternDict[v] = true
}
log.Println("[db] Walk", root, patternDict)
tags := make([]*Tag, 0)
for _, tagID := range tagIDs {
tag, err := database.GetTag(tagID)
if err != nil {
return err
}
tags = append(tags, tag)
}
tx, err := database.sqlConn.Begin()
if err != nil {
log.Fatal(err)
}
insertFolderStmt := tx.Stmt(database.stmt.insertFolder)
insertFileStmt := tx.Stmt(database.stmt.insertFile)
putTagOnFileStmt := tx.Stmt(database.stmt.putTagOnFile)
findFolderStmt := tx.Stmt(database.stmt.findFolder)
findFileStmt := tx.Stmt(database.stmt.findFile)
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// check pattern
ext := filepath.Ext(info.Name())
if _, ok := patternDict[ext]; !ok {
return nil
}
// insert file and folder
// fileID, err := database.Insert(path, info.Size())
// if err != nil {
// return err
// }
var folderID int64
folder, filename := filepath.Split(path)
err = findFolderStmt.QueryRow(folder).Scan(&folderID)
if err != nil {
result, err := insertFolderStmt.Query(folder, filepath.Base(folder))
if err != nil {
return err
}
for result.Next() {
err = result.Scan(&folderID)
if err != nil {
return err
}
}
}
// try find file id
var fileID int64
result, err := findFileStmt.Query(folderID, filename)
if err != nil {
return err
}
for result.Next() {
err = result.Scan(&fileID)
if err != nil {
return err
}
}
// insert new file
result, err = insertFileStmt.Query(folderID, filename, filename, info.Size())
if err != nil {
return err
}
for result.Next() {
err = result.Scan(&fileID)
if err != nil {
return err
}
}
for _, tag := range tags {
_, err := putTagOnFileStmt.Exec(tag.ID, fileID, userID)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
return tx.Commit()
}
func (database *Database) GetFolder(folderId int64) (*Folder, error) {
folder := &Folder{
Db: database,
}
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
err := database.stmt.getFolder.QueryRow(folderId).Scan(&folder.Folder)
if err != nil {
return nil, err
}
return folder, nil
}
func (database *Database) SearchFiles(filename string, limit int64, offset int64) ([]File, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.searchFiles.Query("%"+filename+"%", limit, offset)
if err != nil {
return nil, errors.New("Error searching files at query " + err.Error())
}
defer rows.Close()
files := make([]File, 0)
for rows.Next() {
var file File = File{
Db: database,
}
err = rows.Scan(&file.ID, &file.Folder_id, &file.Filename, &file.Foldername, &file.Filesize)
if err != nil {
return nil, errors.New("Error scanning SearchFiles " + err.Error())
}
files = append(files, file)
}
if err = rows.Err(); err != nil {
return nil, errors.New("Error scanning SearchFiles exit without full result" + err.Error())
}
return files, nil
}
func (database *Database) FindFolder(folder string) (int64, error) {
var id int64
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
err := database.stmt.findFolder.QueryRow(folder).Scan(&id)
if err != nil {
return 0, err
}
return id, nil
}
func (database *Database) FindFile(folderId int64, filename string) (int64, error) {
var id int64
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
err := database.stmt.findFile.QueryRow(folderId, filename).Scan(&id)
if err != nil {
return 0, err
}
return id, nil
}
func (database *Database) InsertFolder(folder string) (int64, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
result, err := database.stmt.insertFolder.Exec(folder, filepath.Base(folder))
if err != nil {
return 0, err
}
lastInsertId, err := result.LastInsertId()
if err != nil {
return 0, err
}
return lastInsertId, nil
}
func (database *Database) InsertFile(folderId int64, filename string, filesize int64) (int64, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
result, err := database.stmt.insertFile.Exec(folderId, filename, filename, filesize)
if err != nil {
return 0, err
}
lastInsertId, err := result.LastInsertId()
if err != nil {
return 0, err
}
return lastInsertId, nil
}
func (database *Database) Insert(path string, filesize int64) (int64, error) {
folder, filename := filepath.Split(path)
folderId, err := database.FindFolder(folder)
if err != nil {
folderId, err = database.InsertFolder(folder)
if err != nil {
return 0, err
}
}
// if file exists, skip it
lastInsertId, err := database.FindFile(folderId, filename)
if err == nil {
return lastInsertId, nil
}
lastInsertId, err = database.InsertFile(folderId, filename, filesize)
if err != nil {
return 0, err
}
return lastInsertId, nil
}
func (database *Database) UpdateFoldername(folderId int64, foldername string) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.updateFoldername.Exec(foldername, folderId)
if err != nil {
return err
}
return nil
}
func (database *Database) DeleteFile(fileId int64) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
// begin transaction
tx, err := database.sqlConn.Begin()
if err != nil {
return err
}
// delete file
_, err = tx.Stmt(database.stmt.deleteFile).Exec(fileId)
if err != nil {
tx.Rollback()
return err
}
// delete tag on file
_, err = tx.Stmt(database.stmt.deleteFileReferenceInFileHasTag).Exec(fileId)
if err != nil {
tx.Rollback()
return err
}
// delete reviews on file
_, err = tx.Stmt(database.stmt.deleteFileReferenceInReviews).Exec(fileId)
if err != nil {
tx.Rollback()
return err
}
// commit transaction
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (database *Database) UpdateFilename(fileId int64, filename string) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.updateFilename.Exec(filename, fileId)
if err != nil {
return err
}
return nil
}
func (database *Database) ResetFilename(fileId int64) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.resetFilename.Exec(fileId)
if err != nil {
return err
}
return nil
}
func (database *Database) ResetFoldername(folderId int64) error {
folder, err := database.GetFolder(folderId)
if err != nil {
return err
}
foldername := filepath.Base(folder.Folder)
err = database.UpdateFoldername(folderId, foldername)
if err != nil {
return err
}
return nil
}

View 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
}

View File

@@ -0,0 +1,7 @@
package database
func (database *Database) RecordPlayback(playback Playback) error {
_, err := database.stmt.recordPlaybackStmt.Exec(
playback.UserID, playback.FileID, playback.Time, playback.Method, playback.Duration)
return err
}

View File

@@ -0,0 +1,123 @@
package database
func (database *Database) InsertReview(review *Review) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.insertReview.Exec(
review.UserId,
review.FileId,
review.CreatedAt,
review.Content)
return err
}
func (database *Database) GetReviewsOnFile(fileId int64) ([]*Review, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getReviewsOnFile.Query(fileId)
if err != nil {
return nil, err
}
defer rows.Close()
reviews := make([]*Review, 0)
for rows.Next() {
review := &Review{
User: &User{},
File: &File{},
}
err := rows.Scan(
&review.ID,
&review.CreatedAt,
&review.UpdatedAt,
&review.Content,
&review.User.ID,
&review.User.Username,
&review.User.Role,
&review.User.AvatarId,
&review.File.ID,
&review.File.Filename)
if err != nil {
return nil, err
}
reviews = append(reviews, review)
}
return reviews, nil
}
func (database *Database) GetReview(reviewId int64) (*Review, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
row := database.stmt.getReview.QueryRow(reviewId)
review := &Review{}
err := row.Scan(
&review.ID,
&review.FileId,
&review.UserId,
&review.CreatedAt,
&review.UpdatedAt,
&review.Content)
if err != nil {
return nil, err
}
return review, nil
}
func (database *Database) UpdateReview(review *Review) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.updateReview.Exec(
review.Content,
review.UpdatedAt,
review.ID)
return err
}
func (database *Database) DeleteReview(reviewId int64) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.deleteReview.Exec(reviewId)
return err
}
func (database *Database) GetReviewsByUser(userId int64) ([]*Review, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getReviewsByUser.Query(userId)
if err != nil {
return nil, err
}
defer rows.Close()
reviews := make([]*Review, 0)
for rows.Next() {
review := &Review{
User: &User{},
File: &File{},
}
err := rows.Scan(
&review.ID,
&review.CreatedAt,
&review.UpdatedAt,
&review.Content,
&review.User.ID,
&review.User.Username,
&review.User.Role,
&review.User.AvatarId,
&review.File.ID,
&review.File.Filename)
if err != nil {
return nil, err
}
reviews = append(reviews, review)
}
return reviews, nil
}

116
pkg/database/method_tag.go Normal file
View File

@@ -0,0 +1,116 @@
package database
import "errors"
func (database *Database) InsertTag(tag *Tag) (int64, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
result, err := database.stmt.insertTag.Query(tag.Name, tag.Description, tag.CreatedByUserId)
if err != nil {
return 0, err
}
var id int64
for result.Next() {
err = result.Scan(&id)
if err != nil {
return 0, err
}
}
if err != nil {
return 0, err
}
return id, nil
}
func (database *Database) GetTag(id int64) (*Tag, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
tag := &Tag{CreatedByUser: &User{}}
err := database.stmt.getTag.QueryRow(id).Scan(
&tag.ID, &tag.Name, &tag.Description,
&tag.CreatedByUser.ID, &tag.CreatedByUser.Username, &tag.CreatedByUser.Role, &tag.CreatedByUser.AvatarId)
if err != nil {
return nil, err
}
return tag, nil
}
func (database *Database) GetTags() ([]*Tag, error) {
tags := []*Tag{}
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getTags.Query()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
tag := &Tag{CreatedByUser: &User{}}
err := rows.Scan(
&tag.ID, &tag.Name, &tag.Description,
&tag.CreatedByUser.ID, &tag.CreatedByUser.Username, &tag.CreatedByUser.Role, &tag.CreatedByUser.AvatarId)
if err != nil {
return nil, err
}
tags = append(tags, tag)
}
return tags, nil
}
func (database *Database) UpdateTag(tag *Tag) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
result, err := database.stmt.updateTag.Exec(tag.Name, tag.Description, tag.ID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.New("No rows affected")
}
return nil
}
// delete tag and all its references in file_has_tag
func (database *Database) DeleteTag(id int64) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
// begin transaction
tx, err := database.sqlConn.Begin()
if err != nil {
return err
}
// delete tag
_, err = tx.Stmt(database.stmt.deleteTag).Exec(id)
if err != nil {
tx.Rollback()
return err
}
// delete file_has_tag
_, err = tx.Stmt(database.stmt.deleteTagReferenceInFileHasTag).Exec(id)
if err != nil {
tx.Rollback()
return err
}
// commit transaction
err = tx.Commit()
if err != nil {
return err
}
return nil
}

View 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
View File

@@ -0,0 +1,171 @@
package database
import (
"golang.org/x/crypto/bcrypt"
"log"
)
func (database *Database) Login(username string, password string) (*User, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
user := &User{}
// get user from database
err := database.stmt.getUser.QueryRow(username).Scan(&user.ID, &user.Username, &user.Password, &user.Role, &user.Active, &user.AvatarId)
if err != nil {
return user, err
}
// validate password
err = database.ComparePassword(user.Password, password)
if err != nil {
return user, err
}
return user, nil
}
func (database *Database) LoginAsAnonymous() (*User, error) {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
user := &User{}
// get user from database
err := database.stmt.getAnonymousUser.QueryRow().Scan(&user.ID, &user.Username, &user.Role, &user.AvatarId)
if err != nil {
return user, err
}
return user, nil
}
func (database *Database) Register(username string, password string, usertype int64) error {
countAdmin, err := database.CountAdmin()
if err != nil {
return err
}
active := false
if countAdmin == 0 {
active = true
}
// active normal user by default
if usertype == 2 {
active = true
}
// encrypt password
password = database.EncryptPassword(password)
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err = database.stmt.insertUser.Exec(username, password, usertype, active, 0)
if err != nil {
return err
}
return nil
}
func (database *Database) GetUserById(id int64) (*User, error) {
user := &User{}
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
// get user from database
err := database.stmt.getUserById.QueryRow(id).Scan(&user.ID, &user.Username, &user.Role, &user.Active, &user.AvatarId)
if err != nil {
return user, err
}
return user, nil
}
func (database *Database) CountAdmin() (int64, error) {
var count int64
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
err := database.stmt.countAdmin.QueryRow().Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (database *Database) GetUsers() ([]*User, error) {
users := make([]*User, 0)
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
rows, err := database.stmt.getUsers.Query()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
user := &User{}
err = rows.Scan(&user.ID, &user.Username, &user.Role, &user.Active, &user.AvatarId)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
func (database *Database) UpdateUserActive(id int64, active bool) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.updateUserActive.Exec(active, id)
if err != nil {
return err
}
return nil
}
func (database *Database) UpdateUsername(id int64, username string) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
_, err := database.stmt.updateUsername.Exec(username, id)
if err != nil {
return err
}
return nil
}
func (database *Database) UpdateUserPassword(id int64, password string) error {
database.singleThreadLock.Lock()
defer database.singleThreadLock.Unlock()
// encrypt password
password = database.EncryptPassword(password)
_, err := database.stmt.updateUserPassword.Exec(password, id)
if err != nil {
return err
}
return nil
}
func (database *Database) EncryptPassword(password string) string {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
log.Println("[database] Failed to hash password, using plaintext password")
return password
}
return string(hash)
}
func (database *Database) ComparePassword(hashedPassword string, plainTextPassword string) error {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainTextPassword))
return err
}

782
pkg/database/sql_stmt.go Normal file
View File

@@ -0,0 +1,782 @@
package database
import (
"database/sql"
"log"
)
var initFilesTableQuery = `CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folders(id),
realname TEXT NOT NULL,
filename TEXT NOT NULL,
filesize INTEGER NOT NULL,
UNIQUE (folder_id, realname)
);`
var initFoldersTableQuery = `CREATE TABLE IF NOT EXISTS folders (
id SERIAL PRIMARY KEY,
folder TEXT NOT NULL UNIQUE,
foldername TEXT NOT NULL
);`
var initFeedbacksTableQuery = `CREATE TABLE IF NOT EXISTS feedbacks (
id SERIAL PRIMARY KEY,
time INTEGER NOT NULL,
content TEXT NOT NULL,
user_id INTEGER NOT NULL,
header TEXT NOT NULL
);`
// User table schema definition
// role: 0 - Anonymous User, 1 - Admin, 2 - User
// postgres avatar references problem
var initUsersTableQuery = `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role INTEGER NOT NULL,
active BOOLEAN NOT NULL,
avatar_id INTEGER NOT NULL DEFAULT 0
);`
var initAvatarsTableQuery = `CREATE TABLE IF NOT EXISTS avatars (
id SERIAL PRIMARY KEY,
avatarname TEXT NOT NULL,
avatar BYTEA NOT NULL
);`
var initTagsTableQuery = `CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL REFERENCES users(id)
);`
var initFileHasTagTableQuery = `CREATE TABLE IF NOT EXISTS file_has_tag (
file_id INTEGER NOT NULL REFERENCES files(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
user_id INTEGER NOT NULL REFERENCES users(id),
PRIMARY KEY (file_id, tag_id)
);`
var initLikesTableQuery = `CREATE TABLE IF NOT EXISTS likes (
user_id INTEGER NOT NULL REFERENCES users(id),
file_id INTEGER NOT NULL REFERENCES files(id),
PRIMARY KEY (user_id, file_id)
);`
var initReviewsTableQuery = `CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
file_id INTEGER NOT NULL REFERENCES files(id),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT 0,
content TEXT NOT NULL
);`
var initPlaybacksTableQuery = `CREATE TABLE IF NOT EXISTS playbacks (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
file_id INTEGER NOT NULL REFERENCES files(id),
time TIMESTAMP NOT NULL,
method INTEGER NOT NULL,
duration INTERVAL NOT NULL
);`
var initLogsTableQuery = `CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
time INTEGER NOT NULL,
message TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id)
);`
var initTmpfsTableQuery = `CREATE TABLE IF NOT EXISTS tmpfs (
id SERIAL PRIMARY KEY,
path TEXT NOT NULL,
size INTEGER NOT NULL,
file_id INTEGER NOT NULL REFERENCES files(id),
ffmpeg_config TEXT NOT NULL,
created_time INTEGER NOT NULL,
accessed_time INTEGER NOT NULL
);`
var insertFolderQuery = `INSERT INTO folders (folder, foldername)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING id;
;`
var findFolderQuery = `SELECT id FROM folders WHERE folder = $1 LIMIT 1;`
var findFileQuery = `SELECT id FROM files WHERE folder_id = $1 AND realname = $2 LIMIT 1;`
var insertFileQuery = `INSERT INTO files (folder_id, realname, filename, filesize)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING id;`
var searchFilesQuery = `SELECT
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
FROM files
JOIN folders ON files.folder_id = folders.id
WHERE filename ILIKE $1
ORDER BY folders.foldername, files.filename
LIMIT $2 OFFSET $3;`
var getFolderQuery = `SELECT folder FROM folders WHERE id = $1 LIMIT 1;`
var dropFilesQuery = `DROP TABLE files;`
var dropFolderQuery = `DROP TABLE folders;`
var getFileQuery = `SELECT
files.id, files.folder_id, files.realname, files.filename, folders.foldername, files.filesize
FROM files
JOIN folders ON files.folder_id = folders.id
WHERE files.id = $1
LIMIT 1;`
var searchFoldersQuery = `SELECT
id, folder, foldername
FROM folders
WHERE foldername ILIKE $1
ORDER BY foldername
LIMIT $2 OFFSET $3;`
var getFilesInFolderQuery = `SELECT
files.id, files.filename, files.filesize, folders.foldername, folders.folder
FROM files
JOIN folders ON files.folder_id = folders.id
WHERE folder_id = $1
ORDER BY files.filename
LIMIT $2 OFFSET $3;`
var getRandomFilesQuery = `SELECT
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
FROM files
JOIN folders ON files.folder_id = folders.id
ORDER BY RANDOM()
LIMIT $1;`
var getRandomFilesWithTagQuery = `SELECT
files.id, files.folder_id, files.filename, folders.foldername, files.filesize
FROM file_has_tag
JOIN files ON file_has_tag.file_id = files.id
JOIN folders ON files.folder_id = folders.id
WHERE file_has_tag.tag_id = $1
ORDER BY RANDOM()
LIMIT $2;`
var insertFeedbackQuery = `INSERT INTO feedbacks (time, content, user_id, header)
VALUES ($1, $2, $3, $4);`
var getFeedbacksQuery = `SELECT
feedbacks.id, feedbacks.time, feedbacks.content, feedbacks.header,
users.id, users.username, users.role, users.active, users.avatar_id
FROM feedbacks
JOIN users ON feedbacks.user_id = users.id
ORDER BY feedbacks.time
;`
var deleteFeedbackQuery = `DELETE FROM feedbacks WHERE id = $1;`
var insertUserQuery = `INSERT INTO users (username, password, role, active, avatar_id)
VALUES ($1, $2, $3, $4, $5);`
var countUserQuery = `SELECT count(*) FROM users;`
var countAdminQuery = `SELECT count(*) FROM users WHERE role= 1;`
var getUserQuery = `SELECT id, username, password, role, active, avatar_id FROM users WHERE username = $1 LIMIT 1;`
var getUsersQuery = `SELECT id, username, role, active, avatar_id FROM users;`
var getUserByIdQuery = `SELECT id, username, role, active, avatar_id FROM users WHERE id = $1 LIMIT 1;`
var updateUserActiveQuery = `UPDATE users SET active = $1 WHERE id = $2;`
var updateUsernameQuery = `UPDATE users SET username = $1 WHERE id = $2;`
var updateUserPasswordQuery = `UPDATE users SET password = $1 WHERE id = $2;`
var getAnonymousUserQuery = `SELECT id, username, role, avatar_id FROM users WHERE role = 0 LIMIT 1;`
var insertTagQuery = `INSERT INTO tags (name, description, created_by_user_id) VALUES ($1, $2, $3) RETURNING id;`
var deleteTagQuery = `DELETE FROM tags WHERE id = $1;`
var getTagQuery = `SELECT
tags.id, tags.name, tags.description,
users.id, users.username, users.role, users.avatar_id
FROM tags
JOIN users ON tags.created_by_user_id = users.id
WHERE tags.id = $1 LIMIT 1;`
var getTagsQuery = `SELECT
tags.id, tags.name, tags.description,
users.id, users.username, users.role, users.avatar_id
FROM tags
JOIN users ON tags.created_by_user_id = users.id
ORDER BY tags.name
;`
var updateTagQuery = `UPDATE tags SET name = $1, description = $2 WHERE id = $3;`
// postgres INSERT IGNORE
var putTagOnFileQuery = `INSERT INTO file_has_tag (tag_id, file_id, user_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;`
var getTagsOnFileQuery = `SELECT
tags.id, tags.name, tags.description, tags.created_by_user_id
FROM file_has_tag
JOIN tags ON file_has_tag.tag_id = tags.id
WHERE file_has_tag.file_id = $1
ORDER BY tags.name
;`
var deleteTagOnFileQuery = `DELETE FROM file_has_tag WHERE tag_id = $1 AND file_id = $2;`
var deleteTagReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE tag_id = $1;`
var updateFoldernameQuery = `UPDATE folders SET foldername = $1 WHERE id = $2;`
var insertReviewQuery = `INSERT INTO reviews (user_id, file_id, created_at, content)
VALUES ($1, $2, $3, $4);`
var getReviewsOnFileQuery = `SELECT
reviews.id, reviews.created_at, reviews.updated_at, reviews.content,
users.id, users.username, users.role, users.avatar_id,
files.id, files.filename
FROM reviews
JOIN users ON reviews.user_id = users.id
JOIN files ON reviews.file_id = files.id
WHERE reviews.file_id = $1
ORDER BY reviews.created_at
;`
var getReviewQuery = `SELECT id, file_id, user_id, created_at, updated_at, content FROM reviews WHERE id = $1 LIMIT 1;`
var updateReviewQuery = `UPDATE reviews SET content = $1, updated_at = $2 WHERE id = $3;`
var deleteReviewQuery = `DELETE FROM reviews WHERE id = $1;`
var getReviewsByUserQuery = `SELECT
reviews.id, reviews.created_at, reviews.updated_at, reviews.content,
users.id, users.username, users.role, users.avatar_id,
files.id, files.filename
FROM reviews
JOIN users ON reviews.user_id = users.id
JOIN files ON reviews.file_id = files.id
WHERE reviews.user_id = $1
ORDER BY reviews.created_at
;`
var deleteFileQuery = `DELETE FROM files WHERE id = $1;`
var deleteFileReferenceInFileHasTagQuery = `DELETE FROM file_has_tag WHERE file_id = $1;`
var deleteFileReferenceInReviewsQuery = `DELETE FROM reviews WHERE file_id = $1;`
var updateFilenameQuery = `UPDATE files SET filename = $1 WHERE id = $2;`
var resetFilenameQuery = `UPDATE files SET filename = realname WHERE id = $1;`
var recordPlaybackQuery = `INSERT INTO playbacks (user_id, file_id, time, method, duration) VALUES ($1, $2, $3, $4, $5);`
type Stmt struct {
initFilesTable *sql.Stmt
initFoldersTable *sql.Stmt
initFeedbacksTable *sql.Stmt
initUsersTable *sql.Stmt
initAvatarsTable *sql.Stmt
initTagsTable *sql.Stmt
initFileHasTag *sql.Stmt
initLikesTable *sql.Stmt
initReviewsTable *sql.Stmt
initPlaybacksTable *sql.Stmt
initLogsTable *sql.Stmt
initTmpfsTable *sql.Stmt
insertFolder *sql.Stmt
insertFile *sql.Stmt
findFolder *sql.Stmt
findFile *sql.Stmt
searchFiles *sql.Stmt
getFolder *sql.Stmt
dropFiles *sql.Stmt
dropFolder *sql.Stmt
getFile *sql.Stmt
searchFolders *sql.Stmt
getFilesInFolder *sql.Stmt
getRandomFiles *sql.Stmt
getRandomFilesWithTag *sql.Stmt
insertFeedback *sql.Stmt
getFeedbacks *sql.Stmt
deleteFeedback *sql.Stmt
insertUser *sql.Stmt
countUser *sql.Stmt
countAdmin *sql.Stmt
getUser *sql.Stmt
getUsers *sql.Stmt
getUserById *sql.Stmt
updateUserActive *sql.Stmt
updateUsername *sql.Stmt
updateUserPassword *sql.Stmt
getAnonymousUser *sql.Stmt
insertTag *sql.Stmt
deleteTag *sql.Stmt
getTag *sql.Stmt
getTags *sql.Stmt
updateTag *sql.Stmt
putTagOnFile *sql.Stmt
getTagsOnFile *sql.Stmt
deleteTagOnFile *sql.Stmt
deleteTagReferenceInFileHasTag *sql.Stmt
updateFoldername *sql.Stmt
insertReview *sql.Stmt
getReviewsOnFile *sql.Stmt
getReview *sql.Stmt
updateReview *sql.Stmt
deleteReview *sql.Stmt
getReviewsByUser *sql.Stmt
deleteFile *sql.Stmt
deleteFileReferenceInFileHasTag *sql.Stmt
deleteFileReferenceInReviews *sql.Stmt
updateFilename *sql.Stmt
resetFilename *sql.Stmt
recordPlaybackStmt *sql.Stmt
}
func NewPreparedStatement(sqlConn *sql.DB) (*Stmt, error) {
var err error
stmt := &Stmt{}
// init folders table
stmt.initFoldersTable, err = sqlConn.Prepare(initFoldersTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initFoldersTable.Exec()
if err != nil {
return nil, err
}
// init files table
stmt.initFilesTable, err = sqlConn.Prepare(initFilesTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initFilesTable.Exec()
if err != nil {
return nil, err
}
// init avatars table
stmt.initAvatarsTable, err = sqlConn.Prepare(initAvatarsTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initAvatarsTable.Exec()
if err != nil {
return nil, err
}
// init users table
stmt.initUsersTable, err = sqlConn.Prepare(initUsersTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initUsersTable.Exec()
if err != nil {
return nil, err
}
// init feedbacks tables
stmt.initFeedbacksTable, err = sqlConn.Prepare(initFeedbacksTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initFeedbacksTable.Exec()
if err != nil {
return nil, err
}
// init tags table
stmt.initTagsTable, err = sqlConn.Prepare(initTagsTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initTagsTable.Exec()
if err != nil {
return nil, err
}
// init file_has_tag table
stmt.initFileHasTag, err = sqlConn.Prepare(initFileHasTagTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initFileHasTag.Exec()
if err != nil {
return nil, err
}
// init likes table
stmt.initLikesTable, err = sqlConn.Prepare(initLikesTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initLikesTable.Exec()
if err != nil {
return nil, err
}
// init reviews table
stmt.initReviewsTable, err = sqlConn.Prepare(initReviewsTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initReviewsTable.Exec()
if err != nil {
return nil, err
}
// init playbacks table
stmt.initPlaybacksTable, err = sqlConn.Prepare(initPlaybacksTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initPlaybacksTable.Exec()
if err != nil {
return nil, err
}
// init logs table
stmt.initLogsTable, err = sqlConn.Prepare(initLogsTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initLogsTable.Exec()
if err != nil {
return nil, err
}
// init tmpfs table
stmt.initTmpfsTable, err = sqlConn.Prepare(initTmpfsTableQuery)
if err != nil {
return nil, err
}
_, err = stmt.initTmpfsTable.Exec()
if err != nil {
return nil, err
}
log.Println("Init tables finished")
// init insert folder statement
stmt.insertFolder, err = sqlConn.Prepare(insertFolderQuery)
if err != nil {
return nil, err
}
// init findFolder statement
stmt.findFolder, err = sqlConn.Prepare(findFolderQuery)
if err != nil {
return nil, err
}
// init findFile statement
stmt.findFile, err = sqlConn.Prepare(findFileQuery)
if err != nil {
return nil, err
}
// init insertFile stmt
stmt.insertFile, err = sqlConn.Prepare(insertFileQuery)
if err != nil {
return nil, err
}
// init searchFile stmt
stmt.searchFiles, err = sqlConn.Prepare(searchFilesQuery)
if err != nil {
return nil, err
}
// init getFolder stmt
stmt.getFolder, err = sqlConn.Prepare(getFolderQuery)
if err != nil {
return nil, err
}
// init dropFolder stmt
stmt.dropFolder, err = sqlConn.Prepare(dropFolderQuery)
if err != nil {
return nil, err
}
// init dropFiles stmt
stmt.dropFiles, err = sqlConn.Prepare(dropFilesQuery)
if err != nil {
return nil, err
}
// init getFile stmt
stmt.getFile, err = sqlConn.Prepare(getFileQuery)
if err != nil {
return nil, err
}
// init searchFolder stmt
stmt.searchFolders, err = sqlConn.Prepare(searchFoldersQuery)
if err != nil {
return nil, err
}
// init getFilesInFolder stmt
stmt.getFilesInFolder, err = sqlConn.Prepare(getFilesInFolderQuery)
if err != nil {
return nil, err
}
// init getRandomFiles
stmt.getRandomFiles, err = sqlConn.Prepare(getRandomFilesQuery)
if err != nil {
return nil, err
}
// init getRandomFilesWithTag
stmt.getRandomFilesWithTag, err = sqlConn.Prepare(getRandomFilesWithTagQuery)
if err != nil {
return nil, err
}
// init insertFeedback
stmt.insertFeedback, err = sqlConn.Prepare(insertFeedbackQuery)
if err != nil {
return nil, err
}
// init getFeedbacks
stmt.getFeedbacks, err = sqlConn.Prepare(getFeedbacksQuery)
if err != nil {
return nil, err
}
// init deleteFeedback
stmt.deleteFeedback, err = sqlConn.Prepare(deleteFeedbackQuery)
if err != nil {
return nil, err
}
// init insertUser
stmt.insertUser, err = sqlConn.Prepare(insertUserQuery)
if err != nil {
return nil, err
}
// init countUser
stmt.countUser, err = sqlConn.Prepare(countUserQuery)
if err != nil {
return nil, err
}
// init countAdmin
stmt.countAdmin, err = sqlConn.Prepare(countAdminQuery)
if err != nil {
return nil, err
}
// init getUser
stmt.getUser, err = sqlConn.Prepare(getUserQuery)
if err != nil {
return nil, err
}
// init getUsers
stmt.getUsers, err = sqlConn.Prepare(getUsersQuery)
if err != nil {
return nil, err
}
// init getUserById
stmt.getUserById, err = sqlConn.Prepare(getUserByIdQuery)
if err != nil {
return nil, err
}
// init updateUserActive
stmt.updateUserActive, err = sqlConn.Prepare(updateUserActiveQuery)
if err != nil {
return nil, err
}
// init updateUsername
stmt.updateUsername, err = sqlConn.Prepare(updateUsernameQuery)
if err != nil {
return nil, err
}
// init updateUserPassword
stmt.updateUserPassword, err = sqlConn.Prepare(updateUserPasswordQuery)
if err != nil {
return nil, err
}
// init getAnonymousUser
stmt.getAnonymousUser, err = sqlConn.Prepare(getAnonymousUserQuery)
if err != nil {
return nil, err
}
// insert Anonymous user if users is empty
userCount := 0
err = stmt.countUser.QueryRow().Scan(&userCount)
if err != nil {
return nil, err
}
if userCount == 0 {
_, err = stmt.insertUser.Exec("Anonymous user", "", 0, 1, 0)
if err != nil {
return nil, err
}
}
// init insertTag
stmt.insertTag, err = sqlConn.Prepare(insertTagQuery)
if err != nil {
return nil, err
}
// init deleteTag
stmt.deleteTag, err = sqlConn.Prepare(deleteTagQuery)
if err != nil {
return nil, err
}
// init getTag
stmt.getTag, err = sqlConn.Prepare(getTagQuery)
if err != nil {
return nil, err
}
// init getTags
stmt.getTags, err = sqlConn.Prepare(getTagsQuery)
if err != nil {
return nil, err
}
// init updateTag
stmt.updateTag, err = sqlConn.Prepare(updateTagQuery)
if err != nil {
return nil, err
}
// init putTagOnFile
stmt.putTagOnFile, err = sqlConn.Prepare(putTagOnFileQuery)
if err != nil {
return nil, err
}
// init getTagsOnFile
stmt.getTagsOnFile, err = sqlConn.Prepare(getTagsOnFileQuery)
if err != nil {
return nil, err
}
// init deleteTagOnFile
stmt.deleteTagOnFile, err = sqlConn.Prepare(deleteTagOnFileQuery)
if err != nil {
return nil, err
}
// init deleteTagReferenceInFileHasTag
stmt.deleteTagReferenceInFileHasTag, err = sqlConn.Prepare(
deleteTagReferenceInFileHasTagQuery)
if err != nil {
return nil, err
}
// init updateFoldername
stmt.updateFoldername, err = sqlConn.Prepare(updateFoldernameQuery)
if err != nil {
return nil, err
}
// init insertReview
stmt.insertReview, err = sqlConn.Prepare(insertReviewQuery)
if err != nil {
return nil, err
}
// init getReviewsOnFile
stmt.getReviewsOnFile, err = sqlConn.Prepare(getReviewsOnFileQuery)
if err != nil {
return nil, err
}
// init getReview
stmt.getReview, err = sqlConn.Prepare(getReviewQuery)
if err != nil {
return nil, err
}
// init updateReview
stmt.updateReview, err = sqlConn.Prepare(updateReviewQuery)
if err != nil {
return nil, err
}
// init deleteReview
stmt.deleteReview, err = sqlConn.Prepare(deleteReviewQuery)
if err != nil {
return nil, err
}
// init getReviewsByUser
stmt.getReviewsByUser, err = sqlConn.Prepare(getReviewsByUserQuery)
if err != nil {
return nil, err
}
// init deleteFile
stmt.deleteFile, err = sqlConn.Prepare(deleteFileQuery)
if err != nil {
return nil, err
}
// init deleteFileReferenceInFileHasTag
stmt.deleteFileReferenceInFileHasTag, err = sqlConn.Prepare(
deleteFileReferenceInFileHasTagQuery)
if err != nil {
return nil, err
}
// init deleteFileReferenceInReviews
stmt.deleteFileReferenceInReviews, err = sqlConn.Prepare(
deleteFileReferenceInReviewsQuery)
if err != nil {
return nil, err
}
// init updateFilename
stmt.updateFilename, err = sqlConn.Prepare(updateFilenameQuery)
if err != nil {
return nil, err
}
// init resetFilename
stmt.resetFilename, err = sqlConn.Prepare(resetFilenameQuery)
if err != nil {
return nil, err
}
stmt.recordPlaybackStmt, err = sqlConn.Prepare(recordPlaybackQuery)
if err != nil {
return nil, err
}
log.Println("Init statements finished")
return stmt, err
}

98
pkg/database/struct.go Normal file
View File

@@ -0,0 +1,98 @@
package database
import (
"path/filepath"
"time"
)
type File struct {
Db *Database `json:"-"`
ID int64 `json:"id"`
Folder_id int64 `json:"folder_id"`
Foldername string `json:"foldername"`
Realname string `json:"-"`
Filename string `json:"filename"`
Filesize int64 `json:"filesize"`
folderCache *Folder
}
type Folder struct {
Db *Database `json:"-"`
ID int64 `json:"id"`
Folder string `json:"folder"`
Foldername string `json:"foldername"`
}
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Role int64 `json:"role"`
Active bool `json:"active"`
AvatarId int64 `json:"avatar_id"`
}
type Tag struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedByUserId int64 `json:"created_by_user_id"`
CreatedByUser *User `json:"created_by_user"`
}
type Review struct {
ID int64 `json:"id"`
FileId int64 `json:"file_id"`
File *File `json:"file"`
UserId int64 `json:"user_id"`
User *User `json:"user"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Content string `json:"content"`
}
type Feedback struct {
ID int64 `json:"id"`
UserId int64 `json:"user_id"`
User *User `json:"user"`
Content string `json:"content"`
Header string `json:"header"`
Time int64 `json:"time"`
}
type Playback struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FileID int64 `json:"file_id"`
Time time.Time `json:"time"`
Method int64 `json:"method"`
Duration time.Duration `json:"Duration"`
}
var (
RoleAnonymous = int64(0)
RoleAdmin = int64(1)
RoleUser = int64(2)
)
func (f *File) Path() (string, error) {
var err error
if f.folderCache == nil {
f.folderCache, err = f.Db.GetFolder(f.Folder_id)
}
if err != nil {
return "", err
}
return filepath.Join(f.folderCache.Folder, f.Realname), nil
}
func (f *File) Dir() (string, error) {
var err error
if f.folderCache == nil {
f.folderCache, err = f.Db.GetFolder(f.Folder_id)
}
if err != nil {
return "", err
}
return f.folderCache.Folder, nil
}

91
pkg/tmpfs/tmpfs.go Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
:8081 {
reverse_proxy /api/* localhost:8080
reverse_proxy * localhost:3000
}

9
web/README.md Normal file
View File

@@ -0,0 +1,9 @@
# MSW Open Music Web Frontend
This is a React single page application. And use Preact instead of React to achieve a smaller file size.
`node_modules` only has 19M. We uses esbuild and shell scripts and build only takes a milliseconds!
## How to build
Simple run `./build.sh`, then all output files are under `./build/` directory.

3
web/axios.min.js vendored

File diff suppressed because one or more lines are too long

6
web/build.sh Executable file
View File

@@ -0,0 +1,6 @@
rm -rf build
cp -raf public build
./node_modules/.bin/esbuild src/index.jsx --bundle --outfile=build/msw-open-music.js --alias:react=preact/compat --alias:react-dom=preact/compat --minify --analyze
cat public/index.html | sed "s/%PUBLIC_URL%/$PUBLIC_URL/" > build/index.html
echo "Build done, output files under ./build directory"

View File

@@ -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>

View File

@@ -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 作为 keyffmpeg_config{} 作为 value
// 为方便前端,此处将 ffmpeg_configs 转为数组,并添加 name 到每个对象中
var ffmpeg_configs = response.data.ffmpeg_configs
var tmp_list = []
for (var key in ffmpeg_configs) {
var ffmpeg_config = ffmpeg_configs[key]
ffmpeg_config.name = key
tmp_list.push(ffmpeg_config)
}
tmp_list.sort()
this.ffmpeg_config_list = tmp_list
this.selected_ffmpeg_config = this.ffmpeg_config_list[0]
}).catch(err => {
this.ffmpeg_config_list = [{name: 'No avaliable config'}]
this.selected_ffmpeg_config = this.ffmpeg_config_list[0]
})
},
watch: {
selected_ffmpeg_config(n, o) {
this.$emit('set_ffmpeg_config', this.selected_ffmpeg_config)
},
},
}
const routes = [
{ path: '/', component: component_get_random_files},
{ path: '/search_files', component: component_search_files},
{ path: '/search_folders', component: component_search_folders},
{ path: '/manage', component: component_manage},
{ path: '/share', component: component_share},
]
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes,
})
const app = Vue.createApp({
data() {
return {
playing_audio_file: {},
token: "default token",
}
},
methods: {
stop() {
this.playing_audio_file = {}
},
set_token(token) {
this.token = token
},
play_audio(file) {
console.log(file)
this.playing_audio_file = file
},
},
})
app.component('component-search-folders', component_search_folders)
app.component('component-manage', component_manage)
app.component('component-file', component_file)
app.component('component-audio-player', component_audio_player)
app.component('component-search-files', component_search_files)
app.component('component-get-random-files', component_get_random_files)
app.component('component-file-dialog', component_file_dialog)
app.component('component-token', component_token)
app.component('component-stream-config', component_stream_config)
app.component('component-manage-database', component_manage_database)
app.component('component-share', component_share)
app.use(router)
app.mount('#app')

738
web/package-lock.json generated Normal file
View File

@@ -0,0 +1,738 @@
{
"name": "msw-open-music-react",
"version": "1.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "msw-open-music-react",
"version": "1.2.0",
"dependencies": {
"@preact/compat": "^17.1.2",
"esbuild": "^0.15.17",
"react-router-dom": "^6.4.4",
"water.css": "^2.1.1"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.15.17.tgz",
"integrity": "sha512-ay6Ken4u+JStjYmqIgh71jMT0bs/rXpCCDKaMfl78B20QYWJglT5P6Ejfm4hWf6Zi+uUWNe7ZmqakRs2BQYIeg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.15.17.tgz",
"integrity": "sha512-IA1O7f7qxw2DX8oqTpugHElr926phs7Rq8ULXleBMk4go5K05BU0mI8BfCkWcYAvcmVaMc13bv5W3LIUlU6Y9w==",
"cpu": [
"loong64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@preact/compat": {
"version": "17.1.2",
"resolved": "https://registry.npmmirror.com/@preact/compat/-/compat-17.1.2.tgz",
"integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==",
"peerDependencies": {
"preact": "*"
}
},
"node_modules/@remix-run/router": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.0.4.tgz",
"integrity": "sha512-gTL8H5USTAKOyVA4xczzDJnC3HMssdFa3tRlwBicXynx9XfiXwneHnYQogwSKpdCkjXISrEKSTtX62rLpNEVQg==",
"engines": {
"node": ">=14"
}
},
"node_modules/esbuild": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.15.17.tgz",
"integrity": "sha512-8MbkDX+kh0kaeYGd6klMbn1uTOXHoDw7UYMd1dQYA5cqBZivf5+pzfaXZSL1RNamJfXW/uWC5+9wX5ejDgpSqg==",
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.15.17",
"@esbuild/linux-loong64": "0.15.17",
"esbuild-android-64": "0.15.17",
"esbuild-android-arm64": "0.15.17",
"esbuild-darwin-64": "0.15.17",
"esbuild-darwin-arm64": "0.15.17",
"esbuild-freebsd-64": "0.15.17",
"esbuild-freebsd-arm64": "0.15.17",
"esbuild-linux-32": "0.15.17",
"esbuild-linux-64": "0.15.17",
"esbuild-linux-arm": "0.15.17",
"esbuild-linux-arm64": "0.15.17",
"esbuild-linux-mips64le": "0.15.17",
"esbuild-linux-ppc64le": "0.15.17",
"esbuild-linux-riscv64": "0.15.17",
"esbuild-linux-s390x": "0.15.17",
"esbuild-netbsd-64": "0.15.17",
"esbuild-openbsd-64": "0.15.17",
"esbuild-sunos-64": "0.15.17",
"esbuild-windows-32": "0.15.17",
"esbuild-windows-64": "0.15.17",
"esbuild-windows-arm64": "0.15.17"
}
},
"node_modules/esbuild-android-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.15.17.tgz",
"integrity": "sha512-sUs6cKMAuAyWnJ/66ezWVr9SMRGFSwoMagxzdhXYggSA12zF7krXSuc1Y9JwxHq56wtv/gFAVo97TFm7RBc1Ig==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-android-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.17.tgz",
"integrity": "sha512-RLZuCgIx1rexwxwsXTEW40ZiZzdBI1MBphwDRFyms/iiJGwLxqCH7v75iSJk5s6AF6oa80KC6r/RmzyaX/uJNg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.17.tgz",
"integrity": "sha512-+6RTCZ0hfAb+RqTNq1uVsBcP441yZOSi6CyV9BIBryGGVg8RM3Bc6L45e5b68jdRloddN92ekS50e4ElI+cHQA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.17.tgz",
"integrity": "sha512-ne4UWUHEKWLgYSE5SLr0/TBcID3k9LPnrzzRXzFLTfD+ygjnW1pMEgdMfmOKIe8jYBUYv8x/YoksriTdQb9r/Q==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.17.tgz",
"integrity": "sha512-6my3DrwLOe1zhR8UzVRKeo9AFM9XkApJBcx0IE+qKaEbKKBxYAiDBtd2ZMtRA2agqIwRP0kuHofTiDEzpfA+ZA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.17.tgz",
"integrity": "sha512-LQL7+f+bz+xmAu1FcDBB304Wm2CjONUcOeF4f3TqG7wYXMxjjYQZBFv+0OVapNXyYrM2vy9JMDbps+SheuOnHg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-32": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.15.17.tgz",
"integrity": "sha512-7E9vZXMZhINQ4/KcxBxioJ2ao5gbXJ6Pa4/LEUd102g3gadSalpg0LrityFgw7ao6qmjcNWwdEYrXaDnOzyyYA==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.15.17.tgz",
"integrity": "sha512-TnedHtFQSUVlc0J0D4ZMMalYaQ0Zbt7HSwGy4sav7BlXVqDVc/rchJ/a9dathK51apzLgRyXQMseLf6bkloaSQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.17.tgz",
"integrity": "sha512-+ugCmBTTDIlh+UuC7E/GvyJqjGTX2pNOA+g3isG78aYcfgswrHjvstTtIfljaU95AS30qrVNLgI5h/8TsRWTrg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.17.tgz",
"integrity": "sha512-oupYfh0lTHg+F/2ZoTNrioB+KLd6x0Zlhjz2Oa1jhl8wCGkNvwe25RytR2/SGPYpoNVcvCeoayWQRwwRuWGgfQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-mips64le": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.17.tgz",
"integrity": "sha512-aUVyHwUXJF1hi9jsAT+At+cBxZh2yGICi/e757N6d/zzOD+eVK3PKQj68tAvIflx6/ZpnuCTKol1GpgGYrzERg==",
"cpu": [
"mips64el"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-ppc64le": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.17.tgz",
"integrity": "sha512-i7789iFTLfLccHPNADCbaZPx9CuQblsBqv2j4XqIBN1jKIJbpQ8iqCkWoHep4PLqqKLtBLtTWh919GsrFGdeJA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-riscv64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.17.tgz",
"integrity": "sha512-fEQ/8tnZ2sDniBlPfTXEdg+0OP1olps96HvYdwl8ywJdAlD7AK761EL3lRbRdfMHNOId2N6+CVca43/Fiu/0AQ==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-s390x": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.17.tgz",
"integrity": "sha512-ZBQekST4gYgTKHAvUJtR1kFFulHTDlRZSE8T0wRQCmQqydNkC1teWxlR31xS6MZevjZGfa7OMVJD24bBhei/2Q==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-netbsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.17.tgz",
"integrity": "sha512-onNBFaZVN9GzGJMm3aZJJv74n/Q8FjW20G9OfSDhHjvamqJ5vbd42hNk6igQX4lgBCHTZvvBlWDJAMy+tbJAAw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-openbsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.17.tgz",
"integrity": "sha512-QFxHmvjaRrmTCvH/A3EmzqKUSZHRQ7/pbrJeATsb/Q6qckCeL9e7zg/1A3HiZqDXeBUV3yNeBeV1GJBjY6yVyA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-sunos-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.17.tgz",
"integrity": "sha512-7dHZA8Kc6U8rBTKojJatXtzHTUKJ3CRYimvOGIQQ1yUDOqGx/zZkCH/HkEi3Zg5SWyDj/57E5e1YJPo4ySSw/w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-32": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.15.17.tgz",
"integrity": "sha512-yDrNrwQ/0k4N3OZItZ6k6YnBUch8+of06YRYc3hFI8VDm7X1rkNZwhttZNAzF6+TtbnK4cIz7H2/EwdSoaGZ3g==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.15.17.tgz",
"integrity": "sha512-jPnXvB4zMMToNPpCBdt+OEQiYFVs9wlQ5G8vMoJkrYJBp1aEt070MRpBFa6pfBFrgXquqgUiNAohMcTdy+JVFg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.17.tgz",
"integrity": "sha512-I5QeSsz0X66V8rxVhmw03Wzn8Tz63H3L9GrsA7C5wvBXMk3qahLWuEL+l7SZ2DleKkFeZZMu1dPxOak9f1TZ4A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"peer": true
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-router": {
"version": "6.4.4",
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.4.4.tgz",
"integrity": "sha512-SA6tSrUCRfuLWeYsTJDuriRqfFIsrSvuH7SqAJHegx9ZgxadE119rU8oOX/rG5FYEthpdEaEljdjDlnBxvfr+Q==",
"dependencies": {
"@remix-run/router": "1.0.4"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.4.4",
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.4.4.tgz",
"integrity": "sha512-0Axverhw5d+4SBhLqLpzPhNkmv7gahUwlUVIOrRLGJ4/uwt30JVajVJXqv2Qr/LCwyvHhQc7YyK1Do8a9Jj7qA==",
"dependencies": {
"@remix-run/router": "1.0.4",
"react-router": "6.4.4"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/water.css": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/water.css/-/water.css-2.1.1.tgz",
"integrity": "sha512-gkO5byC+pZ7ndEV18hs/RmxKoDtEZXx06tZU4ocI3IBdv4xV64tlhjIFbDjurysRnNkiy2oQTr8PakRyzZWPJw=="
}
},
"dependencies": {
"@esbuild/android-arm": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.15.17.tgz",
"integrity": "sha512-ay6Ken4u+JStjYmqIgh71jMT0bs/rXpCCDKaMfl78B20QYWJglT5P6Ejfm4hWf6Zi+uUWNe7ZmqakRs2BQYIeg==",
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.15.17.tgz",
"integrity": "sha512-IA1O7f7qxw2DX8oqTpugHElr926phs7Rq8ULXleBMk4go5K05BU0mI8BfCkWcYAvcmVaMc13bv5W3LIUlU6Y9w==",
"optional": true
},
"@preact/compat": {
"version": "17.1.2",
"resolved": "https://registry.npmmirror.com/@preact/compat/-/compat-17.1.2.tgz",
"integrity": "sha512-7pOZN9lMDDRQ+6aWvjwTp483KR8/zOpfS83wmOo3zfuLKdngS8/5RLbsFWzFZMGdYlotAhX980hJ75bjOHTwWg==",
"requires": {}
},
"@remix-run/router": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.0.4.tgz",
"integrity": "sha512-gTL8H5USTAKOyVA4xczzDJnC3HMssdFa3tRlwBicXynx9XfiXwneHnYQogwSKpdCkjXISrEKSTtX62rLpNEVQg=="
},
"esbuild": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.15.17.tgz",
"integrity": "sha512-8MbkDX+kh0kaeYGd6klMbn1uTOXHoDw7UYMd1dQYA5cqBZivf5+pzfaXZSL1RNamJfXW/uWC5+9wX5ejDgpSqg==",
"requires": {
"@esbuild/android-arm": "0.15.17",
"@esbuild/linux-loong64": "0.15.17",
"esbuild-android-64": "0.15.17",
"esbuild-android-arm64": "0.15.17",
"esbuild-darwin-64": "0.15.17",
"esbuild-darwin-arm64": "0.15.17",
"esbuild-freebsd-64": "0.15.17",
"esbuild-freebsd-arm64": "0.15.17",
"esbuild-linux-32": "0.15.17",
"esbuild-linux-64": "0.15.17",
"esbuild-linux-arm": "0.15.17",
"esbuild-linux-arm64": "0.15.17",
"esbuild-linux-mips64le": "0.15.17",
"esbuild-linux-ppc64le": "0.15.17",
"esbuild-linux-riscv64": "0.15.17",
"esbuild-linux-s390x": "0.15.17",
"esbuild-netbsd-64": "0.15.17",
"esbuild-openbsd-64": "0.15.17",
"esbuild-sunos-64": "0.15.17",
"esbuild-windows-32": "0.15.17",
"esbuild-windows-64": "0.15.17",
"esbuild-windows-arm64": "0.15.17"
}
},
"esbuild-android-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.15.17.tgz",
"integrity": "sha512-sUs6cKMAuAyWnJ/66ezWVr9SMRGFSwoMagxzdhXYggSA12zF7krXSuc1Y9JwxHq56wtv/gFAVo97TFm7RBc1Ig==",
"optional": true
},
"esbuild-android-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.17.tgz",
"integrity": "sha512-RLZuCgIx1rexwxwsXTEW40ZiZzdBI1MBphwDRFyms/iiJGwLxqCH7v75iSJk5s6AF6oa80KC6r/RmzyaX/uJNg==",
"optional": true
},
"esbuild-darwin-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.17.tgz",
"integrity": "sha512-+6RTCZ0hfAb+RqTNq1uVsBcP441yZOSi6CyV9BIBryGGVg8RM3Bc6L45e5b68jdRloddN92ekS50e4ElI+cHQA==",
"optional": true
},
"esbuild-darwin-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.17.tgz",
"integrity": "sha512-ne4UWUHEKWLgYSE5SLr0/TBcID3k9LPnrzzRXzFLTfD+ygjnW1pMEgdMfmOKIe8jYBUYv8x/YoksriTdQb9r/Q==",
"optional": true
},
"esbuild-freebsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.17.tgz",
"integrity": "sha512-6my3DrwLOe1zhR8UzVRKeo9AFM9XkApJBcx0IE+qKaEbKKBxYAiDBtd2ZMtRA2agqIwRP0kuHofTiDEzpfA+ZA==",
"optional": true
},
"esbuild-freebsd-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.17.tgz",
"integrity": "sha512-LQL7+f+bz+xmAu1FcDBB304Wm2CjONUcOeF4f3TqG7wYXMxjjYQZBFv+0OVapNXyYrM2vy9JMDbps+SheuOnHg==",
"optional": true
},
"esbuild-linux-32": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.15.17.tgz",
"integrity": "sha512-7E9vZXMZhINQ4/KcxBxioJ2ao5gbXJ6Pa4/LEUd102g3gadSalpg0LrityFgw7ao6qmjcNWwdEYrXaDnOzyyYA==",
"optional": true
},
"esbuild-linux-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.15.17.tgz",
"integrity": "sha512-TnedHtFQSUVlc0J0D4ZMMalYaQ0Zbt7HSwGy4sav7BlXVqDVc/rchJ/a9dathK51apzLgRyXQMseLf6bkloaSQ==",
"optional": true
},
"esbuild-linux-arm": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.17.tgz",
"integrity": "sha512-+ugCmBTTDIlh+UuC7E/GvyJqjGTX2pNOA+g3isG78aYcfgswrHjvstTtIfljaU95AS30qrVNLgI5h/8TsRWTrg==",
"optional": true
},
"esbuild-linux-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.17.tgz",
"integrity": "sha512-oupYfh0lTHg+F/2ZoTNrioB+KLd6x0Zlhjz2Oa1jhl8wCGkNvwe25RytR2/SGPYpoNVcvCeoayWQRwwRuWGgfQ==",
"optional": true
},
"esbuild-linux-mips64le": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.17.tgz",
"integrity": "sha512-aUVyHwUXJF1hi9jsAT+At+cBxZh2yGICi/e757N6d/zzOD+eVK3PKQj68tAvIflx6/ZpnuCTKol1GpgGYrzERg==",
"optional": true
},
"esbuild-linux-ppc64le": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.17.tgz",
"integrity": "sha512-i7789iFTLfLccHPNADCbaZPx9CuQblsBqv2j4XqIBN1jKIJbpQ8iqCkWoHep4PLqqKLtBLtTWh919GsrFGdeJA==",
"optional": true
},
"esbuild-linux-riscv64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.17.tgz",
"integrity": "sha512-fEQ/8tnZ2sDniBlPfTXEdg+0OP1olps96HvYdwl8ywJdAlD7AK761EL3lRbRdfMHNOId2N6+CVca43/Fiu/0AQ==",
"optional": true
},
"esbuild-linux-s390x": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.17.tgz",
"integrity": "sha512-ZBQekST4gYgTKHAvUJtR1kFFulHTDlRZSE8T0wRQCmQqydNkC1teWxlR31xS6MZevjZGfa7OMVJD24bBhei/2Q==",
"optional": true
},
"esbuild-netbsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.17.tgz",
"integrity": "sha512-onNBFaZVN9GzGJMm3aZJJv74n/Q8FjW20G9OfSDhHjvamqJ5vbd42hNk6igQX4lgBCHTZvvBlWDJAMy+tbJAAw==",
"optional": true
},
"esbuild-openbsd-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.17.tgz",
"integrity": "sha512-QFxHmvjaRrmTCvH/A3EmzqKUSZHRQ7/pbrJeATsb/Q6qckCeL9e7zg/1A3HiZqDXeBUV3yNeBeV1GJBjY6yVyA==",
"optional": true
},
"esbuild-sunos-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.17.tgz",
"integrity": "sha512-7dHZA8Kc6U8rBTKojJatXtzHTUKJ3CRYimvOGIQQ1yUDOqGx/zZkCH/HkEi3Zg5SWyDj/57E5e1YJPo4ySSw/w==",
"optional": true
},
"esbuild-windows-32": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.15.17.tgz",
"integrity": "sha512-yDrNrwQ/0k4N3OZItZ6k6YnBUch8+of06YRYc3hFI8VDm7X1rkNZwhttZNAzF6+TtbnK4cIz7H2/EwdSoaGZ3g==",
"optional": true
},
"esbuild-windows-64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.15.17.tgz",
"integrity": "sha512-jPnXvB4zMMToNPpCBdt+OEQiYFVs9wlQ5G8vMoJkrYJBp1aEt070MRpBFa6pfBFrgXquqgUiNAohMcTdy+JVFg==",
"optional": true
},
"esbuild-windows-arm64": {
"version": "0.15.17",
"resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.17.tgz",
"integrity": "sha512-I5QeSsz0X66V8rxVhmw03Wzn8Tz63H3L9GrsA7C5wvBXMk3qahLWuEL+l7SZ2DleKkFeZZMu1dPxOak9f1TZ4A==",
"optional": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"preact": {
"version": "10.11.3",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"peer": true
},
"react": {
"version": "18.2.0",
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
},
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
}
},
"react-router": {
"version": "6.4.4",
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.4.4.tgz",
"integrity": "sha512-SA6tSrUCRfuLWeYsTJDuriRqfFIsrSvuH7SqAJHegx9ZgxadE119rU8oOX/rG5FYEthpdEaEljdjDlnBxvfr+Q==",
"requires": {
"@remix-run/router": "1.0.4"
}
},
"react-router-dom": {
"version": "6.4.4",
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.4.4.tgz",
"integrity": "sha512-0Axverhw5d+4SBhLqLpzPhNkmv7gahUwlUVIOrRLGJ4/uwt30JVajVJXqv2Qr/LCwyvHhQc7YyK1Do8a9Jj7qA==",
"requires": {
"@remix-run/router": "1.0.4",
"react-router": "6.4.4"
}
},
"scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
},
"water.css": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/water.css/-/water.css-2.1.1.tgz",
"integrity": "sha512-gkO5byC+pZ7ndEV18hs/RmxKoDtEZXx06tZU4ocI3IBdv4xV64tlhjIFbDjurysRnNkiy2oQTr8PakRyzZWPJw=="
}
}
}

14
web/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "msw-open-music-react",
"version": "1.2.0",
"private": true,
"dependencies": {
"@preact/compat": "^17.1.2",
"esbuild": "^0.15.17",
"react-router-dom": "^6.4.4",
"water.css": "^2.1.1"
},
"scripts": {
"build": "bash ./build.sh"
}
}

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

19
web/public/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Personal music streaming platform" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="stylesheet" href="%PUBLIC_URL%/msw-open-music.css" />
<meta name="mobile-web-app-capable" content="yes" />
<title>MSW Open Music</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="%PUBLIC_URL%/msw-open-music.js"></script>
</body>
</html>

14
web/public/manifest.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

3
web/src/.prettierrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"tabWidth": 2
}

121
web/src/App.css Normal file
View File

@@ -0,0 +1,121 @@
html {
font-size: 1em;
}
body {
margin: auto;
padding-top: 1rem;
max-width: unset;
min-height: 100vh;
}
#root {
display: flex;
justify-content: center;
}
.base {
display: grid;
grid-row-gap: 1em;
max-width: 800px;
width: 100%;
}
.header {
color: white;
background-color: rgb(63, 81, 181);
box-shadow: 0 0 8px #393939;
border-radius: 6px 6px 0 0;
}
.title {
margin-left: 1em;
margin-right: 1em;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
}
.title-text {
margin-left: 1em;
margin-right: 1em;
}
.logo {
width: 39px;
height: 39px;
border-radius: 6px;
}
.nav {
display: flex;
justify-content: space-evenly;
}
.nav-link {
color: rgb(229, 232, 245);
padding: 1em;
}
a.unset {
color: unset;
text-decoration: unset;
}
a.active {
color: deeppink;
background-color: lightgray;
border-radius: 0.39em 0.39em 0 0;
}
.audio-player {
height: 39px;
width: 100%;
}
td.clickable {
cursor: pointer;
}
div.clickable {
cursor: pointer;
}
div.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
div.search_toolbar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
width: 100%;
}
div.feedback {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
button.refresh {
width: 100%;
}
td,
th {
padding-bottom: 0.5em;
padding-top: 0.5em;
}
dialog {
border: solid;
}
.player-options {
display: flex;
}
.ffmpeg-config {
display: flex;
justify-content: space-between;
}
.horizontal {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.vertical {
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
.warp-word {
overflow-wrap: anywhere;
}
.number-input {
width: 5em;
}

177
web/src/App.jsx Normal file
View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import { HashRouter as Router, Routes, Route, NavLink } from "react-router-dom";
import "./App.css";
import GetRandomFiles from "./component/GetRandomFiles";
import SearchFiles from "./component/SearchFiles";
import SearchFolders from "./component/SearchFolders";
import FilesInFolder from "./component/FilesInFolder";
import Manage from "./component/Manage";
import ManageUser from "./component/ManageUser";
import FileInfo from "./component/FileInfo";
import Share from "./component/Share";
import Login from "./component/Login";
import Register from "./component/Register";
import Tags from "./component/Tags";
import EditTag from "./component/EditTag";
import EditReview from "./component/EditReview";
import AudioPlayer from "./component/AudioPlayer";
import UserStatus from "./component/UserStatus";
import ReviewPage from "./component/ReviewPage";
import UserProfile from "./component/UserProfile";
import FeedbackPage from "./component/FeedbackPage";
import { useEffect, useState } from "react";
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
function App() {
const [playingFile, setPlayingFile] = useState({});
const [user, setUser] = useState({});
const [langCode, setLangCode] = useState("en_US");
useEffect(() => {
if (playingFile.id === undefined) {
return;
}
const html = document.getElementsByTagName("html")[0];
const retStyle = html.style;
const bodyRetStyle = document.body.style
html.style = `
backdrop-filter: blur(10px);
background-size: cover;
background-attachment: fixed;
background-position: center;
background-image: url("/api/v1/get_file_avatar?id=${playingFile.id}");
`;
document.body.style.opacity = 0.88;
return () => {
html.style = retStyle;
document.body.style = bodyRetStyle;
};
}, [playingFile.id]);
// select language
useEffect(() => {
const browserCode = window.navigator.language;
for (const key in LANG_OPTIONS) {
for (const i in LANG_OPTIONS[key].matches) {
const code = LANG_OPTIONS[key].matches[i];
if (code === browserCode) {
setLangCode(key);
return;
}
}
}
// fallback to english
setLangCode("en-US");
}, []);
return (
<div className="base">
<langCodeContext.Provider value={{ langCode, setLangCode }}>
<Router>
<header className="header">
<h3 className="title">
<img src="favicon.png" alt="logo" className="logo" />
<span className="title-text">MSW Open Music Project</span>
<UserStatus user={user} setUser={setUser} />
</h3>
<nav className="nav">
<NavLink to="/" className="nav-link">
{Tr("Feeling luckly")}
</NavLink>
<NavLink to="/files" className="nav-link">
{Tr("Files")}
</NavLink>
<NavLink to="/folders" className="nav-link">
{Tr("Folders")}
</NavLink>
<NavLink to="/manage" className="nav-link">
{Tr("Manage")}
</NavLink>
</nav>
</header>
<main>
<Routes>
<Route
index
path="/"
element={<GetRandomFiles setPlayingFile={setPlayingFile} />}
/>
<Route
path="/files"
element={<SearchFiles setPlayingFile={setPlayingFile} />}
/>
<Route
path="/folders"
element={<SearchFolders setPlayingFile={setPlayingFile} />}
/>
<Route
path="/folders/:id"
element={<FilesInFolder setPlayingFile={setPlayingFile} />}
/>
<Route
path="/manage"
element={
<Manage
user={user}
setUser={setUser}
setLangCode={setLangCode}
/>
}
/>
<Route
path="/manage/feedbacks"
element={<FeedbackPage user={user} />}
/>
<Route
path="/manage/login"
element={<Login user={user} setUser={setUser} />}
/>
<Route
path="/manage/register"
element={<Register user={user} setUser={setUser} />}
/>
<Route path="/manage/tags" element={<Tags user={user} />} />
<Route
path="/manage/tags/:id"
element={<EditTag user={user} />}
/>
<Route
path="/manage/reviews/:id"
element={<EditReview user={user} />}
/>
<Route
path="/manage/users"
element={<ManageUser user={user} setUser={setUser} />}
/>
<Route
path="/manage/users/:id"
element={<UserProfile user={user} setUser={setUser} />}
/>
<Route
path="/files/:id"
element={<FileInfo setPlayingFile={setPlayingFile} />}
/>
<Route
path="/files/:id/share"
element={<Share setPlayingFile={setPlayingFile} />}
/>
<Route
path="/files/:id/review"
element={
<ReviewPage user={user} setPlayingFile={setPlayingFile} />
}
/>
</Routes>
</main>
<AudioPlayer
playingFile={playingFile}
setPlayingFile={setPlayingFile}
/>
</Router>
</langCodeContext.Provider>
</div>
);
}
export default App;

8
web/src/App.test.js Normal file
View 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();
});

View File

@@ -0,0 +1,275 @@
import * as React from 'react';
import {useEffect, useState} from "react";
import {useNavigate} from "react-router";
import {CalcReadableFilesizeDetail} from "./Common";
import FfmpegConfig from "./FfmpegConfig";
import FileDialog from "./FileDialog";
import {Tr} from "../translate";
function AudioPlayer(props) {
// props.playingFile
// props.setPlayingFile
const [fileDialogShowStatus, setFileDialogShowStatus] = useState(false);
const [loop, setLoop] = useState(true);
const [raw, setRaw] = useState(false);
const [prepare, setPrepare] = useState(false);
const [selectedFfmpegConfig, setSelectedFfmpegConfig] = useState({
name: "",
args: "",
});
const [playingURL, setPlayingURL] = useState("");
const [isPreparing, setIsPreparing] = useState(false);
const [timerCount, setTimerCount] = useState(0);
const [timerID, setTimerID] = useState(null);
const [beginPlayTime, setBeginPlayTime] = useState(null);
const [lastID, setLastID] = useState(null);
const recordPlaybackHistory = async (file_id, method) => {
if (file_id === null) {
return
}
const player = document.getElementById('dom-player')
const endPlayTime = new Date()
let duration = parseInt((endPlayTime - beginPlayTime) / 1000)
const maxDuration = parseInt(player.duration)
// treat 85% of duration as finished
if (duration / maxDuration >= 0.85) {
method = 1
}
duration = duration < maxDuration ? duration : maxDuration
setBeginPlayTime(endPlayTime)
await fetch('/api/v1/record_playback', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
playback: {
file_id,
method,
duration,
},
})
})
}
// init mediaSession API
useEffect(() => {
if (navigator.mediaSession) {
navigator.mediaSession.setActionHandler("stop", () => {
props.setPlayingFile({});
});
}
}, []);
const updatePlayMode = () => {
if (props.playingFile.id === undefined) {
return
}
if (raw) {
console.log("Play raw file");
setPlayingURL("/api/v1/get_file_direct?id=" + props.playingFile.id);
} else {
if (prepare) {
// prepare file
setIsPreparing(true);
fetch("/api/v1/prepare_file_stream_direct", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
id: props.playingFile.id,
config_name: selectedFfmpegConfig.name,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
setIsPreparing(false);
return;
}
props.setPlayingFile(data.file);
setIsPreparing(false);
setPlayingURL(
`/api/v1/get_file_stream_direct?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
);
});
} else {
setPlayingURL(
`/api/v1/get_file_stream?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
);
}
}
}
useEffect(() => {
// media session related staff
if (navigator.mediaSession) {
navigator.mediaSession.metadata = new window.MediaMetadata({
title: props.playingFile.filename,
album: props.playingFile.foldername,
artwork: [{src: "/favicon.png", type: "image/png"}],
});
}
// no playing file
if (props.playingFile.id === undefined) {
// 3 music stopped
recordPlaybackHistory(lastID, 3)
setPlayingURL("");
return;
}
// crrently playing file, record interupt
if (playingURL) {
// 2 music changed
recordPlaybackHistory(lastID, 2)
}
setLastID(props.playingFile.id)
// have playingFile, record begin time
setBeginPlayTime(new Date())
updatePlayMode()
}, [props.playingFile.id]);
useEffect(() => {
updatePlayMode()
}, [raw, prepare, selectedFfmpegConfig])
let navigate = useNavigate();
return (
<footer className="vertical">
<h5>{Tr("Player status")}</h5>
{props.playingFile.id && (
<span>
<FileDialog
showStatus={fileDialogShowStatus}
setShowStatus={setFileDialogShowStatus}
file={props.playingFile}
setPlayingFile={() => {
return;
}}
/>
<button
onClick={() => {
setFileDialogShowStatus(!fileDialogShowStatus);
}}
>
{props.playingFile.filename}
</button>
<button
onClick={() => navigate(`/folders/${props.playingFile.folder_id}`)}
>
{props.playingFile.foldername}
</button>
<button disabled>
{CalcReadableFilesizeDetail(props.playingFile.filesize)}
</button>
{isPreparing && <button disabled>{Tr("Preparing...")}</button>}
{playingURL !== "" && (
<button
onClick={() => {
props.setPlayingFile({});
}}
>
{Tr("Stop")}
</button>
)}
</span>
)}
<br />
<span className="horizontal">
<input
className="number-input"
disabled={timerID !== null}
type="number"
value={timerCount}
onChange={(e) => {
setTimerCount(e.target.value);
}}
/>
<button
onClick={() => {
if (timerID != null) {
clearInterval(timerID);
setTimerID(null);
return;
}
setTimerID(
setTimeout(() => {
props.setPlayingFile({});
setTimerID(null);
}, timerCount * 1000 * 60)
);
}}
>
{Tr("Stop Timer")}
</button>
</span>
<span>
<span>
<input
checked={loop}
onChange={(event) => setLoop(event.target.checked)}
type="checkbox"
/>
<label>{Tr("Loop")}</label>
</span>
<span>
<input
checked={raw}
onChange={(event) => setRaw(event.target.checked)}
type="checkbox"
/>
<label>{Tr("Raw")}</label>
</span>
{!raw && (
<span>
<input
checked={prepare}
onChange={(event) => setPrepare(event.target.checked)}
type="checkbox"
/>
<label>{Tr("Prepare")}</label>
</span>
)}
</span>
<audio
id="dom-player"
controls
autoPlay
className="audio-player"
src={playingURL}
onEnded={async () => {
const player = document.getElementById('dom-player')
if (loop) {
player.play()
}
// 1 music finished
recordPlaybackHistory(props.playingFile.id, 1)
}}
onPlay={async () => {
setBeginPlayTime(new Date());
}}
></audio>
<FfmpegConfig
selectedFfmpegConfig={selectedFfmpegConfig}
setSelectedFfmpegConfig={setSelectedFfmpegConfig}
/>
</footer >
);
}
export default AudioPlayer;

View 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";
}

View File

@@ -0,0 +1,115 @@
import * as React from 'react';
import { useState, useEffect, useContext } from "react";
import { Tr, tr, langCodeContext } from "../translate";
function Database() {
const [walkPath, setWalkPath] = useState("");
const [patternString, setPatternString] = useState(
"wav flac mp3 ogg m4a mka webm"
);
const [tags, setTags] = useState([]);
const [selectedTags, setSelectedTags] = useState([]);
const [updating, setUpdating] = useState(false);
const { langCode } = useContext(langCodeContext);
function getTags() {
fetch("/api/v1/get_tags")
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setTags(data.tags);
}
});
}
useEffect(() => {
getTags();
}, []);
function updateDatabase() {
// split pattern string into array
let patternArray = patternString.split(" ");
// remove whitespace from array
patternArray = patternArray.map((item) => item.trim());
// remove empty strings from array
patternArray = patternArray.filter((item) => item !== "");
// add dot before item array
patternArray = patternArray.map((item) => "." + item);
setUpdating(true);
fetch("/api/v1/walk", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
root: walkPath,
pattern: patternArray,
tag_ids: selectedTags,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert("Database updated");
}
})
.finally(() => {
setUpdating(false);
});
}
return (
<div>
<h3>{Tr("Update Database")}</h3>
<input
type="text"
value={walkPath}
placeholder={tr("walk path", langCode)}
onChange={(e) => setWalkPath(e.target.value)}
/>
<input
type="text"
value={patternString}
placeholder={tr("pattern wav flac mp3", langCode)}
onChange={(e) => setPatternString(e.target.value)}
/>
<div>
<h4>{Tr("Tags")}</h4>
{tags.map((tag) => (
<div key={tag.id}>
<input
id={tag.id}
type="checkbox"
value={tag.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedTags([...selectedTags, tag.id]);
} else {
setSelectedTags(
selectedTags.filter((item) => item !== tag.id)
);
}
}}
/>
<label htmlFor={tag.id}>{tag.name}</label>
</div>
))}
</div>
<button
onClick={() => {
updateDatabase();
}}
disabled={updating}
>
{updating ? Tr("Updating...") : Tr("Update Database")}
</button>
</div>
);
}
export default Database;

View File

@@ -0,0 +1,102 @@
import * as React from 'react';
import { useContext, useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router";
import { tr, Tr, langCodeContext } from "../translate";
function SingleReview() {
let params = useParams();
let navigate = useNavigate();
const { langCode } = useContext(langCodeContext)
const [review, setReview] = useState({
id: "",
user_id: "",
file_id: "",
content: "",
created_at: "",
updated_at: "",
});
function refresh() {
fetch("/api/v1/get_review", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setReview(data.review);
}
});
}
function save() {
fetch("/api/v1/update_review", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
content: review.content,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Review updated", langCode));
navigate(-1);
}
});
}
function deleteReview() {
fetch("/api/v1/delete_review", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Review deleted", langCode));
navigate(-1);
}
});
}
useEffect(() => {
refresh();
}, []);
return (
<div className="page">
<h3>{Tr("Edit Review")}</h3>
<textarea
value={review.content}
onChange={(e) => setReview({ ...review, content: e.target.value })}
></textarea>
<div>
<button onClick={() => deleteReview()}>{Tr("Delete")}</button>
<button onClick={() => save()}>{Tr("Save")}</button>
</div>
</div>
);
}
export default SingleReview;

View File

@@ -0,0 +1,143 @@
import * as React from 'react';
import { useState, useEffect, useContext } from "react";
import { useParams, useNavigate } from "react-router";
import { tr, Tr, langCodeContext } from "../translate";
function EditTag() {
let params = useParams();
let navigate = useNavigate();
const { langCode } = useContext(langCodeContext);
const [tag, setTag] = useState({
id: "",
name: "",
description: "",
created_by_user: {
id: "",
username: "",
role: "",
avatar_id: "",
},
});
function refreshTagInfo() {
fetch("/api/v1/get_tag_info", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setTag(data.tag);
}
});
}
function updateTagInfo() {
fetch("/api/v1/update_tag", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
name: tag.name,
description: tag.description,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Tag updated successfully", langCode));
refreshTagInfo();
}
});
}
useEffect(() => {
refreshTagInfo();
}, []);
function deleteTag() {
fetch("/api/v1/delete_tag", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Tag deleted successfully", langCode));
navigate(-1);
}
});
}
return (
<div className="page">
<h3>{Tr("Edit Tag")}</h3>
<div>
<label htmlFor="id">{Tr("ID")}</label>
<input
type="text"
disabled
name="id"
id="id"
value={tag.id}
onChange={(e) => setTag({ ...tag, id: e.target.value })}
/>
<label htmlFor="name">{Tr("Created by")}</label>
<input
type="text"
disabled
name="created_by_user_username"
id="created_by_user_username"
value={tag.created_by_user.username}
onChange={(e) =>
setTag({
...tag,
created_by_user: {
...tag.created_by_user,
username: e.target.value,
},
})
}
/>
<label htmlFor="name">{Tr("Name")}</label>
<input
type="text"
name="name"
id="name"
value={tag.name}
onChange={(e) => setTag({ ...tag, name: e.target.value })}
/>
<label htmlFor="description">{Tr("Description")}</label>
<textarea
name="description"
id="description"
value={tag.description}
onChange={(e) => setTag({ ...tag, description: e.target.value })}
/>
<button onClick={deleteTag}>{Tr("Delete")}</button>
<button onClick={() => updateTagInfo()}>{Tr("Save")}</button>
</div>
</div>
);
}
export default EditTag;

View File

@@ -0,0 +1,107 @@
import * as React from 'react';
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { convertIntToDateTime } from "./Common";
import { Tr } from "../translate";
function FeedbackPage() {
const [content, setContext] = useState("");
const [feedbacks, setFeedbacks] = useState([]);
function getFeedbacks() {
fetch("/api/v1/get_feedbacks")
.then((res) => res.json())
.then((data) => {
if (data.error) {
console.log(data.error);
} else {
setFeedbacks(data.feedbacks);
}
});
}
function submitFeedback() {
fetch("/api/v1/feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: content,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setContext("");
getFeedbacks();
}
});
}
useEffect(() => {
getFeedbacks();
}, []);
return (
<div className="page">
<h3>{Tr("Feedbacks")}</h3>
<textarea value={content} onChange={(e) => setContext(e.target.value)} />
<button onClick={() => submitFeedback()}>{Tr("Submit")}</button>
<div>
<table>
<thead>
<tr>
<th>{Tr("User")}</th>
<th>{Tr("Feedback")}</th>
<th>{Tr("Date")}</th>
<th>{Tr("Action")}</th>
</tr>
</thead>
<tbody>
{feedbacks.map((feedback) => (
<tr key={feedback._id}>
<td>
<Link to={`/manage/users/${feedback.user.id}`}>
@{feedback.user.username}
</Link>
</td>
<td>{feedback.content}</td>
<td>{convertIntToDateTime(feedback.time)}</td>
<td>
<button
onClick={() => {
fetch("/api/v1/delete_feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: feedback.id,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
getFeedbacks();
}
});
}}
>
{Tr("Delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default FeedbackPage;

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import { useEffect, useState } from "react";
function FfmpegConfig(props) {
// props.setSelectedFfmpegConfig
// props.selectedFfmpegConfig
const [ffmpegConfigList, setFfmpegConfigList] = useState([]);
useEffect(() => {
fetch("/api/v1/get_ffmpeg_config_list")
.then((response) => response.json())
.then((data) => {
setFfmpegConfigList(data.ffmpeg_config_list);
if (data.ffmpeg_config_list.length > 0) {
props.setSelectedFfmpegConfig(data.ffmpeg_config_list[0]);
}
})
.catch((error) => {
alert("get_ffmpeg_config_list error: " + error);
});
}, []);
return (
<div className="ffmpeg-config">
<select
onChange={(event) => {
props.setSelectedFfmpegConfig(
ffmpegConfigList[event.target.selectedIndex]
);
}}
>
{ffmpegConfigList.map((ffmpegConfig) => (
<option key={ffmpegConfig.name}>{ffmpegConfig.name}</option>
))}
</select>
<span className="warp-word">{props.selectedFfmpegConfig.args}</span>
</div>
);
}
export default FfmpegConfig;

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { useNavigate } from "react-router";
import { Tr } from "../translate";
function FileDialog(props) {
// props.showStatus
// props.setShowStatus
// props.playingFile
// props.setPlayingFile
// props.file
let navigate = useNavigate();
return (
<dialog
open={props.showStatus}
style={{
zIndex: 1,
}}
>
<p
style={{
cursor: "pointer",
}}
onClick={() => {
props.setPlayingFile(props.file);
props.setShowStatus(false);
}}
>
{props.file.filename}
</p>
<p>
{Tr("Play: play using browser player.")}
<br />
{Tr("Info for more actions.")}
</p>
<button
onClick={() => {
navigate(`/files/${props.file.id}`);
props.setShowStatus(false);
}}
>
{Tr("Info")}
</button>
<button
onClick={() => {
props.setPlayingFile(props.file);
props.setShowStatus(false);
}}
>
{Tr("Play")}
</button>
<button onClick={() => props.setShowStatus(false)}>{Tr("Close")}</button>
</dialog>
);
}
export default FileDialog;

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { useState } from "react";
import { useNavigate } from "react-router";
import { CalcReadableFilesize } from "./Common";
import FileDialog from "./FileDialog";
function FileEntry(props) {
const [showStatus, setShowStatus] = useState(false);
let navigate = useNavigate();
return (
<tr>
<td
className="clickable"
onClick={() => {
// double click to play file and close dialog
if (showStatus) {
props.setPlayingFile(props.file);
setShowStatus(false);
return;
}
setShowStatus(true);
}}
>
{props.file.filename}
</td>
<td
className="clickable"
onClick={() => navigate(`/folders/${props.file.folder_id}`)}
>
{props.file.foldername}
</td>
<td>
{CalcReadableFilesize(props.file.filesize)}
<FileDialog
setPlayingFile={props.setPlayingFile}
showStatus={showStatus}
setShowStatus={setShowStatus}
file={props.file}
/>
</td>
</tr>
);
}
export default FileEntry;

View File

@@ -0,0 +1,331 @@
import * as React from 'react';
import {useNavigate, useParams} from "react-router";
import {useContext, useEffect, useState} from "react";
import {Tr, tr, langCodeContext} from "../translate";
function FileInfo(props) {
let navigate = useNavigate();
let params = useParams();
const [file, setFile] = useState({
id: "",
folder_id: "",
foldername: "",
filename: "",
filesize: "",
});
const [tags, setTags] = useState([]);
const [tagsOnFile, setTagsOnFile] = useState([]);
const [selectedTagID, setSelectedTagID] = useState("");
const {langCode} = useContext(langCodeContext);
const [ffprobeInfo, setFfprobeInfo] = useState("");
function refresh() {
fetch(`/api/v1/get_file_info`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setFile(data);
}
});
}
function getTags() {
fetch(`/api/v1/get_tags`)
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setTags(data.tags);
}
});
}
function getTagsOnFile() {
fetch(`/api/v1/get_tags_on_file`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setTagsOnFile(data.tags);
}
});
}
function removeTagOnFile(tag_id) {
fetch(`/api/v1/delete_tag_on_file`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
file_id: parseInt(params.id),
tag_id: tag_id,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
getTagsOnFile();
}
});
}
function deleteFile() {
// show Warning
if (
window.confirm(tr("Are you sure you want to delete this file?", langCode))
) {
fetch(`/api/v1/delete_file`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
navigate(-1);
}
});
}
}
function updateFilename() {
fetch(`/api/v1/update_filename`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
filename: file.filename,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Filename updated", langCode));
refresh();
}
});
}
function resetFilename() {
fetch(`/api/v1/reset_filename`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
refresh();
}
});
}
useEffect(() => {
refresh();
getTags();
getTagsOnFile();
}, []);
const downloadURL = "/api/v1/get_file_direct?id=" + file.id;
return (
<div className="page">
<h3>{Tr("File Details")}</h3>
<div>
<a href={downloadURL} download>
<button>{Tr("Download")}</button>
</a>
<button
onClick={() => {
props.setPlayingFile(file);
}}
>
{Tr("Play")}
</button>
<button
onClick={() => {
navigate(`/files/${params.id}/review`);
}}
>
{Tr("Review")}
</button>
<button
onClick={() => {
navigate(`/files/${params.id}/share`);
}}
>
{Tr("Share")}
</button>
<button
onClick={() => {
deleteFile();
}}
>
{Tr("Delete")}
</button>
</div>
<div>
<label htmlFor="foldername">{Tr("Folder Name")}</label>
<input
type="text"
id="foldername"
value={file.foldername}
onClick={() => {
navigate(`/folders/${file.folder_id}`);
}}
readOnly
/>
<label htmlFor="filename">{Tr("Filename")}</label>
<input
type="text"
id="filename"
value={file.filename}
onChange={(event) => {
setFile({
...file,
filename: event.target.value,
});
}}
/>
<label htmlFor="filesize">{Tr("File size")}</label>
<input type="text" id="filesize" value={file.filesize} readOnly />
</div>
<div className="horizontal">
<button onClick={updateFilename}>{Tr("Save")}</button>
<button onClick={resetFilename}>{Tr("Reset")}</button>
</div>
<div>
<label>{Tr("Tags")}</label>
<ul>
{tagsOnFile.map((tag) => {
return (
<li key={tag.id}>
<button
onClick={() => {
navigate(`/manage/tags/${tag.id}`);
}}
>
{tag.name}
</button>
<button
onClick={() => {
removeTagOnFile(tag.id);
}}
>
{Tr("Remove")}
</button>
</li>
);
})}
</ul>
<div>
<select
onChange={(e) => {
setSelectedTagID(e.target.value);
}}
>
<option value="">{tr("Select a tag", langCode)}</option>
{tags.map((tag) => {
return (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
);
})}
</select>
<button
onClick={() => {
// check empty
if (selectedTagID === "") {
alert(tr("Please select a tag", langCode));
return;
}
fetch(`/api/v1/put_tag_on_file`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
file_id: parseInt(params.id),
tag_id: parseInt(selectedTagID),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
getTagsOnFile();
}
});
}}
>
{Tr("Add tag")}
</button>
</div>
</div>
<button onClick={async () => {
const resp = await fetch(`/api/v1/get_file_ffprobe_info`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
});
const text = await resp.text();
setFfprobeInfo(text);
}}>FFprobe</button>
{ffprobeInfo && <textarea
style={{
height: "30em",
}}
>{ffprobeInfo}</textarea>}
</div>
);
}
export default FileInfo;

View File

@@ -0,0 +1,141 @@
import * as React from 'react';
import {useParams} from "react-router";
import {useState, useEffect} from "react";
import {useNavigate} from "react-router-dom";
import {useQuery} from "./Common";
import FilesTable from "./FilesTable";
import {Tr} from "../translate";
function FilesInFolder(props) {
let params = useParams();
const query = useQuery();
const navigator = useNavigate();
const [files, setFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const offset = parseInt(query.get("o")) || 0;
const [newFoldername, setNewFoldername] = useState("");
const [folderPath, setFolderPath] = useState("");
const limit = 10;
function refresh() {
setIsLoading(true);
fetch("/api/v1/get_files_in_folder", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
folder_id: parseInt(params.id),
offset: offset,
limit: limit,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setFiles(data.files);
setFolderPath(data.folder);
if (data.files.length > 0) {
setNewFoldername(data.files[0].foldername);
}
}
})
.catch((error) => alert(error))
.finally(() => {
setIsLoading(false);
});
}
useEffect(() => {
refresh();
}, [params.id, offset]);
function nextPage() {
navigator(`/folders/${params.id}?o=${offset + limit}`);
}
function lastPage() {
const offsetValue = offset - limit;
if (offsetValue < 0) {
return;
}
navigator(`/folders/${params.id}?o=${offsetValue}`);
}
function updateFoldername() {
setIsLoading(true);
fetch("/api/v1/update_foldername", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
id: parseInt(params.id),
foldername: newFoldername,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
refresh();
}
})
.catch((error) => alert(error))
.finally(() => {
setIsLoading(false);
});
}
function resetFoldername() {
setIsLoading(true);
fetch("/api/v1/reset_foldername", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
refresh();
}
})
.catch((error) => alert(error))
.finally(() => {
setIsLoading(false);
});
}
return (
<div className="page">
<h3>{Tr("Files in Folder")}</h3>
<div className="search_toolbar">
<button onClick={lastPage}>{Tr("Last page")}</button>
<button disabled>
{isLoading
? Tr("Loading...")
: `${offset} - ${offset + files.length}`}
</button>
<button onClick={nextPage}>{Tr("Next page")}</button>
</div>
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
<span>{folderPath}</span>
<div>
<input
type="text"
value={newFoldername}
onChange={(e) => setNewFoldername(e.target.value)}
/>
<div>
<button onClick={() => updateFoldername()}>{Tr("Save")}</button>
<button onClick={() => resetFoldername()}>{Tr("Reset")}</button>
</div>
</div>
</div>
);
}
export default FilesInFolder;

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import FileEntry from "./FileEntry";
import { Tr } from "../translate";
function FilesTable(props) {
if (props.files.length === 0) {
return null;
}
return (
<table>
<thead>
<tr>
<th>{Tr("Filename")}</th>
<th>{Tr("Folder Name")}</th>
<th>{Tr("Size")}</th>
</tr>
</thead>
<tbody>
{props.files.map((file) => (
<FileEntry
setPlayingFile={props.setPlayingFile}
key={file.id}
file={file}
/>
))}
</tbody>
</table>
);
}
export default FilesTable;

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { useNavigate } from "react-router";
import { Tr } from "../translate";
function FoldersTable(props) {
let navigate = useNavigate();
if (props.folders.length === 0) {
return null;
}
return (
<table>
<thead>
<tr>
<th>{Tr("Folder name")}</th>
<th>{Tr("Action")}</th>
</tr>
</thead>
<tbody>
{props.folders.map((folder) => (
<tr key={folder.id}>
<td
onClick={() => navigate(`/folders/${folder.id}`)}
className="clickable"
>
{folder.foldername}
</td>
<td onClick={() => navigate(`/folders/${folder.id}`)}>
<button>{Tr("View")}</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
export default FoldersTable;

View File

@@ -0,0 +1,118 @@
import * as React from 'react';
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "./Common";
import FilesTable from "./FilesTable";
import { Tr, tr, langCodeContext } from "../translate";
function GetRandomFiles(props) {
const [files, setFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [tags, setTags] = useState([]);
const navigator = useNavigate();
const query = useQuery();
const selectedTag = query.get("t") || "";
const { langCode } = useContext(langCodeContext);
const fetchRandomFiles = async () => {
const resp = await fetch("/api/v1/get_random_files");
const json = await resp.json();
return json.files;
};
async function getRandomFiles() {
setIsLoading(true);
fetchRandomFiles()
.then((data) => {
setFiles(data);
})
.catch((error) => {
alert("get_random_files error: " + error);
})
.finally(() => {
setIsLoading(false);
});
}
const fetchRandomFilesWithTag = async (selectedTag) => {
const resp = await fetch("/api/v1/get_random_files_with_tag", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(selectedTag),
}),
});
const json = await resp.json();
return json.files;
};
function getRandomFilesWithTag() {
setIsLoading(true);
fetchRandomFilesWithTag(selectedTag)
.then((files) => {
setFiles(files);
})
.catch((error) => {
alert("get_random_files_with_tag error: " + error);
})
.finally(() => {
setIsLoading(false);
});
}
function refresh() {
if (selectedTag === "") {
getRandomFiles();
} else {
getRandomFilesWithTag();
}
}
function getTags() {
fetch("/api/v1/get_tags")
.then((response) => response.json())
.then((data) => {
setTags(data.tags);
})
.catch((error) => {
alert("get_tags error: " + error);
});
}
useEffect(() => {
getTags();
}, []);
useEffect(() => {
refresh();
}, [selectedTag]);
return (
<div className="page">
<div className="search_toolbar">
<button className="refresh" onClick={() => refresh(setFiles)}>
{isLoading ? Tr("Loading...") : Tr("Refresh")}
</button>
<select
className="tag_select"
onChange={(event) => {
navigator(`/?t=${event.target.value}`);
}}
value={selectedTag}
>
<option value="">{tr("All", langCode)}</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</div>
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
</div>
);
}
export default GetRandomFiles;

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { useNavigate } from "react-router-dom";
import { useContext, useState } from "react";
import { Tr, tr, langCodeContext } from "../translate";
function Login(props) {
let navigate = useNavigate();
let [username, setUsername] = useState("");
let [password, setPassword] = useState("");
const { langCode } = useContext(langCodeContext);
function login() {
if (!username || !password) {
alert(tr("Please enter username and password", langCode));
return;
}
fetch("/api/v1/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
return;
}
props.setUser(data.user);
navigate("/");
});
}
return (
<div className="page">
<h2>{Tr("Login")}</h2>
<label htmlFor="username">{Tr("Username")}</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<label htmlFor="password">{Tr("Password")}</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
e.preventDefault();
login();
}
}}
/>
<span>
<button onClick={login}>{Tr("Login")}</button>
<button
onClick={() => {
navigate("/manage/register");
}}
>
{Tr("Register")}
</button>
</span>
</div>
);
}
export default Login;

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import { useNavigate } from "react-router";
import Database from "./Database";
import { Tr, langCodeContext, LANG_OPTIONS } from "../translate";
import { useContext } from "react";
function Manage(props) {
let navigate = useNavigate();
const { langCode, setLangCode } = useContext(langCodeContext);
return (
<div className="page">
<h2>{Tr("Manage")}</h2>
<p>
{Tr("Hi")}, {props.user.username}
</p>
<select
value={langCode}
onChange={(event) => {
setLangCode(event.target.value);
}}
>
{Object.keys(LANG_OPTIONS).map((code) => {
const langOption = LANG_OPTIONS[code];
return (
<option value={code} key={code}>
{langOption.name}
</option>
);
})}
</select>
{props.user.role === 0 && (
<div>
<button
onClick={() => {
navigate("/manage/login");
}}
>
{Tr("Login")}
</button>
<button
onClick={() => {
navigate("/manage/register");
}}
>
{Tr("Register")}
</button>
</div>
)}
{props.user.role !== 0 && (
<div className="horizontal">
<button
onClick={() => {
navigate(`/manage/users/${props.user.id}`);
}}
>
{Tr("Profile")}
</button>
<button
onClick={() => {
fetch("/api/v1/logout")
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
props.setUser(data.user);
}
});
}}
>
{Tr("Logout")}
</button>
</div>
)}
<hr />
<div className="horizontal">
<button onClick={() => navigate("/manage/tags")}>{Tr("Tags")}</button>
<button onClick={() => navigate("/manage/users")}>{Tr("Users")}</button>
<button onClick={() => navigate("/manage/feedbacks")}>
{Tr("Feedbacks")}
</button>
</div>
<Database />
<p>
<a
href="https://github.com/heimoshuiyu/msw-open-music"
target="_blank"
rel="noreferrer"
>
{Tr("View source code on Github")}
</a>
</p>
</div>
);
}
export default Manage;

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Tr } from "../translate";
function ManageUser() {
const [users, setUsers] = useState([]);
const roleDict = {
0: "Anonymous",
1: "Admin",
2: "User",
};
function getUsers() {
fetch("/api/v1/get_users")
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setUsers(data.users);
}
});
}
useEffect(() => {
getUsers();
}, []);
return (
<div className="page">
<h3>{Tr("Manage User")}</h3>
<table>
<thead>
<tr>
<th>{Tr("Name")}</th>
<th>{Tr("Role")}</th>
<th>{Tr("Active")}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<Link to={`/manage/users/${user.id}`}>@{user.username}</Link>
</td>
<td>{Tr(roleDict[user.role])}</td>
<td>
<input
type="checkbox"
defaultChecked={user.active}
onClick={(e) => {
fetch("/api/v1/update_user_active", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: user.id,
active: e.target.checked,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
getUsers();
}
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default ManageUser;

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { useNavigate } from "react-router-dom";
import { useContext, useState } from "react";
import { tr, Tr, langCodeContext } from "../translate";
function Register() {
let navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [role, setRole] = useState("");
const { langCode } = useContext(langCodeContext);
function register() {
if (!username || !password || !password2 || !role) {
alert(tr("Please fill out all fields", langCode));
} else if (password !== password2) {
alert(tr("Password do not match", langCode));
} else {
fetch("/api/v1/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username,
password: password,
role: parseInt(role),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
navigate("/manage/login");
}
});
}
}
return (
<div className="page">
<h2>{Tr("Register")}</h2>
<label htmlFor="username">{Tr("Username")}</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<label htmlFor="password">{Tr("Password")}</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label htmlFor="password2">{Tr("Confirm Password")}</label>
<input
type="password"
id="password2"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
e.preventDefault();
register();
}
}}
/>
<label htmlFor="role">{Tr("Role")}</label>
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="">{tr("Select a role", langCode)}</option>
<option value="2">{tr("User", langCode)}</option>
<option value="1">{tr("Admin", langCode)}</option>
</select>
<button onClick={register}>{Tr("Register")}</button>
</div>
);
}
export default Register;

View File

@@ -0,0 +1,34 @@
import * as React from 'react';
import { Link } from "react-router-dom";
import { convertIntToDateTime } from "./Common";
import { Tr, tr, langCodeContext } from "../translate";
import { useContext } from "react";
function ReviewEntry(props) {
const { langCode } = useContext(langCodeContext);
return (
<div>
<h4>
<Link to={`/manage/users/${props.review.user.id}`}>
@{props.review.user.username}
</Link>{" "}
{Tr("review")}{" "}
<Link to={`/files/${props.review.file.id}`}>
{props.review.file.filename}
</Link>{" "}
{Tr("on")} {convertIntToDateTime(props.review.created_at)}{" "}
{props.review.updated_at !== 0 &&
`(${tr("modified on", langCode)} ${convertIntToDateTime(
props.review.updated_at
)} ) `}
{(props.user.role === 1 || props.review.user.id === props.user.id) &&
props.user.role !== 0 && (
<Link to={`/manage/reviews/${props.review.id}`}>{Tr("Edit")}</Link>
)}
</h4>
<p>{props.review.content}</p>
</div>
);
}
export default ReviewEntry;

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import { useState, useEffect } from "react";
import { useParams } from "react-router";
import ReviewEntry from "./ReviewEntry";
import { Tr } from "../translate";
function ReviewPage(props) {
let params = useParams();
const [newReview, setNewReview] = useState("");
const [reviews, setReviews] = useState([]);
function refresh() {
fetch("/api/v1/get_reviews_on_file", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setReviews(data.reviews);
}
});
}
useEffect(() => {
refresh();
}, []);
function submitReview() {
fetch("/api/v1/insert_review", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: newReview,
file_id: parseInt(params.id),
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setNewReview("");
refresh();
}
});
}
return (
<div className="page">
<h3>{Tr("Review Page")}</h3>
<div>
{reviews.map((review) => (
<ReviewEntry key={review.id} review={review} user={props.user} />
))}
</div>
<div>
<textarea
value={newReview}
onChange={(e) => setNewReview(e.target.value)}
/>
<button onClick={() => submitReview()}>{Tr("Submit")}</button>
</div>
</div>
);
}
export default ReviewPage;

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { useState, useEffect, useContext } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "./Common";
import FilesTable from "./FilesTable";
import { Tr, tr, langCodeContext } from "../translate";
function SearchFiles(props) {
const navigator = useNavigate();
const [files, setFiles] = useState([]);
const query = useQuery();
const filename = query.get("q") || "";
const [filenameInput, setFilenameInput] = useState(filename);
const offset = parseInt(query.get("o")) || 0;
const [isLoading, setIsLoading] = useState(false);
const limit = 10;
const { langCode } = useContext(langCodeContext);
function searchFiles() {
// check empty filename
if (filename === "") {
return;
}
setIsLoading(true);
fetch("/api/v1/search_files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: filename,
limit: limit,
offset: offset,
}),
})
.then((response) => response.json())
.then((data) => {
const files = data.files ? data.files : [];
setFiles(files);
})
.catch((error) => {
alert("search_files error: " + error);
})
.finally(() => {
setIsLoading(false);
});
}
function nextPage() {
navigator(`/files?q=${filenameInput}&o=${offset + limit}`);
}
function lastPage() {
const offsetValue = offset - limit;
if (offsetValue < 0) {
return;
}
navigator(`/files?q=${filenameInput}&o=${offsetValue}`);
}
useEffect(() => searchFiles(), [offset, filename]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="page">
<h3>{Tr("Search Files")}</h3>
<div className="search_toolbar">
<input
onChange={(event) => setFilenameInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigator(`/files?q=${filenameInput}&o=0`);
}
}}
type="text"
placeholder={tr("Enter filename", langCode)}
value={filenameInput}
/>
<button
onClick={() => {
navigator(`/files?q=${filenameInput}&o=0`);
}}
>
{isLoading ? Tr("Loading...") : Tr("Search")}
</button>
<button onClick={lastPage}>{Tr("Last page")}</button>
<button disabled>
{offset} - {offset + files.length}
</button>
<button onClick={nextPage}>{Tr("Next page")}</button>
</div>
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
</div>
);
}
export default SearchFiles;

View File

@@ -0,0 +1,92 @@
import * as React from 'react';
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "./Common";
import FoldersTable from "./FoldersTable";
import { Tr, tr, langCodeContext } from "../translate";
function SearchFolders() {
const navigator = useNavigate();
const query = useQuery();
const foldername = query.get("q") || "";
const [foldernameInput, setFoldernameInput] = useState(foldername);
const [folders, setFolders] = useState([]);
const offset = parseInt(query.get("o")) || 0;
const [isLoading, setIsLoading] = useState(false);
const limit = 10;
const { langCode } = useContext(langCodeContext);
function searchFolder() {
if (foldername === "") {
return;
}
setIsLoading(true);
fetch("/api/v1/search_folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
foldername: foldername,
limit: limit,
offset: offset,
}),
})
.then((response) => response.json())
.then((data) => {
setFolders(data.folders ? data.folders : []);
})
.catch((error) => {
alert("search_folders error: " + error);
})
.finally(() => {
setIsLoading(false);
});
}
function nextPage() {
navigator(`/folders?q=${foldername}&o=${offset + limit}`);
}
function lastPage() {
const offsetValue = offset - limit;
if (offsetValue < 0) {
return;
}
navigator(`/folders?q=${foldername}&o=${offsetValue}`);
}
useEffect(() => searchFolder(), [offset, foldername]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="page">
<h3>{Tr("Search Folders")}</h3>
<div className="search_toolbar">
<input
onChange={(event) => setFoldernameInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigator(`/folders?q=${foldernameInput}&o=0`);
}
}}
type="text"
placeholder={tr("Enter folder name", langCode)}
value={foldernameInput}
/>
<button
onClick={() => {
navigator(`/folders?q=${foldernameInput}&o=0`);
}}
>
{isLoading ? Tr("Loading...") : Tr("Search")}
</button>
<button onClick={lastPage}>{Tr("Last page")}</button>
<button disabled>
{offset} - {offset + limit}
</button>
<button onClick={nextPage}>{Tr("Next page")}</button>
</div>
<FoldersTable folders={folders} />
</div>
);
}
export default SearchFolders;

View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import { useContext, useEffect, useState } from "react";
import { useParams } from "react-router";
import FilesTable from "./FilesTable";
import { Tr, tr, langCodeContext } from "../translate";
function Share(props) {
let params = useParams();
const { langCode } = useContext(langCodeContext);
const [file, setFile] = useState({});
useEffect(() => {
fetch("/api/v1/get_file_info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
setFile(data);
})
.catch((error) => {
alert("get_file_info error: " + error);
});
}, [params]);
// change title
useEffect(() => {
const oldTitle = document.title;
document.title = `${tr("Share", langCode)}🎵: ${
file.filename
} - MSW Open Music`;
// set title back
return () => {
document.title = oldTitle;
};
}, [file]);
return (
<div className="page">
<h3>{Tr("Share with others!")}</h3>
<p>
{Tr("Share link")}:{" "}
<a href={window.location.href}>{window.location.href}</a>
</p>
<p>
👇 {Tr("Click the filename below to enjoy music!")}
<br />
</p>
<FilesTable setPlayingFile={props.setPlayingFile} files={[file]} />
</div>
);
}
export default Share;

107
web/src/component/Tags.jsx Normal file
View File

@@ -0,0 +1,107 @@
import * as React from 'react';
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Tr } from "../translate";
function Tags() {
const [tags, setTags] = useState([]);
const [newTagName, setNewTagName] = useState("");
const [newTagDescription, setNewTagDescription] = useState("");
const [showAddTag, setShowAddTag] = useState(false);
function refresh() {
fetch("/api/v1/get_tags")
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setTags(data.tags);
}
});
}
useEffect(() => {
refresh();
}, []);
return (
<div className="page">
<h3>{Tr("Tags")}</h3>
<table>
<thead>
<tr>
<th>{Tr("Name")}</th>
<th>{Tr("Description")}</th>
<th>{Tr("Created by")}</th>
<th>{Tr("Action")}</th>
</tr>
</thead>
<tbody>
{tags.map((tag) => (
<tr key={tag.id}>
<td>{tag.name}</td>
<td>{tag.description}</td>
<td>
<Link to={`/manage/users/${tag.created_by_user.id}`}>
@{tag.created_by_user.username}
</Link>
</td>
<td>
<Link to={`/manage/tags/${tag.id}`}>{Tr("Edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
{!showAddTag && (
<button onClick={() => setShowAddTag(true)}>{Tr("Add tag")}</button>
)}
{showAddTag && (
<div>
<label htmlFor="newTagName">{Tr("New Tag Name")}</label>
<input
type="text"
id="newTagName"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
/>
<label htmlFor="newTagDescription">{Tr("New Tag Description")}</label>
<textarea
id="newTagDescription"
value={newTagDescription}
onChange={(e) => setNewTagDescription(e.target.value)}
/>
<button
onClick={() => {
fetch("/api/v1/insert_tag", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newTagName,
description: newTagDescription,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setNewTagName("");
setNewTagDescription("");
refresh();
}
});
}}
>
{Tr("Create tag")}
</button>
</div>
)}
</div>
);
}
export default Tags;

View File

@@ -0,0 +1,171 @@
import * as React from 'react';
import { useState, useEffect, useContext } from "react";
import { useParams } from "react-router";
import ReviewEntry from "./ReviewEntry";
import { Tr, tr, langCodeContext } from "../translate";
function UserProfile(props) {
let params = useParams();
const [reviews, setReviews] = useState([]);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
const [user, setUser] = useState({
id: 0,
username: "",
role: 0,
active: false,
avatar_id: 0,
});
const { langCode } = useContext(langCodeContext);
function getReviews() {
fetch("/api/v1/get_reviews_by_user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setReviews(data);
}
});
}
function getUserInfo() {
fetch("/api/v1/get_user_info", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
setUser(data.user);
}
});
}
useEffect(() => {
getReviews();
getUserInfo();
}, []);
return (
<div className="page">
<h3>{Tr("User Profile")}</h3>
<div className="horizontal">
<input
type="text"
value={user.username}
onChange={(e) => {
setUser({
...user,
username: e.target.value,
});
}}
/>
<button
onClick={() => {
fetch("/api/v1/update_username", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
username: user.username,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
props.setUser({
...props.user,
username: user.username,
});
alert("Username updated successfully!");
getUserInfo();
}
});
}}
disabled={props.user.id !== user.id && props.user.role !== 1}
>
{Tr("Save username")}
</button>
</div>
<div>
<input
type="password"
value={oldPassword}
placeholder={tr("Old password", langCode)}
onChange={(e) => setOldPassword(e.target.value)}
/>
<input
type="password"
value={newPassword}
placeholder={tr("New password", langCode)}
onChange={(e) => setNewPassword(e.target.value)}
/>
<input
type="password"
value={newPasswordConfirm}
placeholder={tr("Confirm new password", langCode)}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
/>
<button
onClick={() => {
fetch("/api/v1/update_user_password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: parseInt(params.id),
old_password: oldPassword,
new_password: newPassword,
new_password_confirm: newPasswordConfirm,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
alert(data.error);
} else {
alert(tr("Password updated successfully!", langCode));
}
});
}}
disabled={
(props.user.id !== user.id && props.user.role !== 1) ||
newPassword !== newPasswordConfirm ||
newPassword.length === 0
}
>
{Tr("Change password")}
</button>
</div>
<h4>{Tr("Reviews")}</h4>
{reviews.map((review) => (
<ReviewEntry key={review.id} review={review} user={props.user} />
))}
</div>
);
}
export default UserProfile;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import { useEffect } from 'react';
function UserStatus(props) {
// props.user
// props.setUser
useEffect(() => {
fetch("/api/v1/login")
.then(res => res.json())
.then(data => {
props.setUser(data.user);
});
}, []);
return <div>{props.user.username}</div>;
}
export default UserStatus;

13
web/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

12
web/src/index.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import 'water.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

5
web/src/setupTests.js Normal file
View 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';

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { createContext } from "react";
import MAP_zh_CN from "./zh_CN";
const LANG_OPTIONS = {
"en-US": {
name: "English",
langMap: {},
matches: ["en-US", "en"],
},
"zh-CN": {
name: "中文(简体)",
langMap: MAP_zh_CN,
matches: ["zh-CN", "zh"],
},
};
const langCodeContext = createContext("en-US");
function tr(text, langCode) {
const option = LANG_OPTIONS[langCode];
if (option === undefined) {
return text;
}
const langMap = LANG_OPTIONS[langCode].langMap;
const translatedText = langMap[text.toLowerCase()];
if (translatedText === undefined) {
return text;
}
return translatedText;
}
function Tr(text) {
return (
<langCodeContext.Consumer>
{({ langCode }) => {
return tr(text, langCode);
}}
</langCodeContext.Consumer>
);
}
export { tr, Tr, LANG_OPTIONS, langCodeContext };

Some files were not shown because too many files have changed in this diff Show More