├── .github
└── workflows
│ └── test.yaml
├── .gitignore
├── LICENSE
├── README.md
├── config.go
├── config_test.go
├── docs
├── directory-browser.png
└── player.png
├── go.mod
├── go.sum
├── main.go
├── media_detector.go
├── media_detector_test.go
├── media_library.go
├── media_library_test.go
├── server.go
├── static
├── document.svg
├── favicon.svg
├── folder.svg
├── pause.svg
├── play-skip-back.svg
├── play-skip-forward.svg
├── play.svg
├── player.js
└── style.css
├── storage.go
├── storage_test.go
└── templates
└── listing.gohtml
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on: [push, pull_request]
3 | jobs:
4 | test:
5 | strategy:
6 | matrix:
7 | go-version: [1.x]
8 | os: [ubuntu-latest, macos-latest, windows-latest]
9 | runs-on: ${{ matrix.os }}
10 | steps:
11 | - name: Install Go
12 | uses: actions/setup-go@v3
13 | with:
14 | go-version: ${{ matrix.go-version }}
15 | - name: Checkout code
16 | uses: actions/checkout@v3
17 | - name: Build
18 | run: go build -v ./...
19 | - name: Test
20 | run: go test -v ./...
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /bsimp
2 | /config.toml
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bsimp
2 |
3 | Bsimp is a minimalistic S3-backed audio library. It lets you play audio files from an S3 bucket with any arbitrary directory structure.
4 |
5 | It works with AWS S3 or any S3 API compatible storage such as DigitalOcean Spaces, Backblaze B2, Cloudflare R2 or MinIO.
6 |
7 | ## Why
8 |
9 | Over the years I acquired a large library of audio files from different sources - Bandcamp, Google Music and I even ripped some CDs myself a decade ago. I wanted a way to listen my audio files from different devices and also have them backed up reliably on cloud storage. S3 solves both of these problems - it serves as a live audio library and as a backup at the same time.
10 |
11 | I didn't want to go with the existing [open source](https://github.com/awesome-selfhosted/awesome-selfhosted#media-streaming---audio-streaming) audio streaming services. I found them resource-heavy, having many dependencies and features I would never use.
12 |
13 | ## Features
14 |
15 | - Cover art support
16 | - Responsive design
17 | - Stateless - no database required
18 |
19 | ## Screenshots
20 |
21 | Directory Browser
22 |
23 |
24 |
25 | Audio Player
26 |
27 |
28 |
29 | ## Configuring
30 |
31 | DigitalOcean Spaces config example:
32 |
33 | ```toml
34 | [s3]
35 | region = "nyc3"
36 | endpoint = "https://nyc3.digitaloceanspaces.com"
37 | bucket = "foo"
38 |
39 | [s3.credentials]
40 | id = "SPACES KEY"
41 | secret = "SPACES SECRET"
42 | ```
43 |
44 | MinIO config example:
45 | ```toml
46 | [s3]
47 | region = "local"
48 | endpoint = "http://localhost:9000"
49 | bucket = "music"
50 | force_path_style = true
51 |
52 | [s3.credentials]
53 | id = "minioadmin"
54 | secret = "minioadmin"
55 | ```
56 |
57 | ## Running
58 |
59 | ```sh
60 | bsimp -config=/etc/bsimp/config.toml -http=":8080"
61 | ```
62 |
63 | ## Security
64 |
65 | Bsimp doesn't have built-in authentication or rate-limiting. The server should never be exposed to the Internet directly to avoid unexpected S3 bills.
66 |
67 | When exposed to the Internet, the server should run behind a full-fledged web server like Nginx with the following features enabled:
68 | - HTTPS
69 | - Authentication
70 | - Rate Limiting
71 |
72 | ## FAQ
73 |
74 | ### What audio formats does it support?
75 |
76 | All audio formats [supported](https://caniuse.com/?search=audio%20format) by the web browser.
77 |
78 | ### Is there a mobile app?
79 |
80 | No, but the web interface works well on mobile phones. The Media Session API lets you control the playback from the notification bar or the lock screen.
81 |
82 | ### Does it support playlists?
83 |
84 | No, Bsimp follows the S3 bucket directory structure.
85 |
86 | ### Does it support transcoding?
87 |
88 | No, audio files are streamed as is.
89 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "os"
7 | "strings"
8 | "time"
9 |
10 | "github.com/pelletier/go-toml/v2"
11 | )
12 |
13 | const Delimiter = "/"
14 |
15 | type Duration time.Duration
16 |
17 | func (d *Duration) UnmarshalText(data []byte) error {
18 | val, err := time.ParseDuration(string(data))
19 | *d = Duration(val)
20 | return err
21 | }
22 |
23 | type S3Credentials struct {
24 | ID string
25 | Secret string
26 | Token string
27 | }
28 |
29 | type S3Config struct {
30 | Region *string
31 | Endpoint *string
32 | Bucket string
33 | BasePrefix string `toml:"base_prefix"`
34 | RequestPresignExpiry Duration `toml:"request_presign_expiry"`
35 | ForcePathStyle bool `toml:"force_path_style"`
36 | Credentials *S3Credentials
37 | }
38 |
39 | type Config struct {
40 | S3 S3Config
41 | }
42 |
43 | var errMissingBucket = errors.New("s3 bucket is required")
44 |
45 | func newConfig(r io.Reader) (*Config, error) {
46 | cfg := &Config{
47 | S3: S3Config{
48 | RequestPresignExpiry: Duration(2 * time.Hour),
49 | },
50 | }
51 | dec := toml.NewDecoder(r)
52 | if err := dec.Decode(cfg); err != nil {
53 | return nil, err
54 | }
55 | if cfg.S3.Bucket == "" {
56 | return nil, errMissingBucket
57 | }
58 | if cfg.S3.BasePrefix != "" && !strings.HasSuffix(cfg.S3.BasePrefix, Delimiter) {
59 | cfg.S3.BasePrefix += Delimiter
60 | }
61 | return cfg, nil
62 | }
63 |
64 | func NewConfig(path string) (*Config, error) {
65 | f, err := os.Open(path)
66 | if err != nil {
67 | return nil, err
68 | }
69 | defer f.Close()
70 | return newConfig(f)
71 | }
72 |
--------------------------------------------------------------------------------
/config_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestConfig(t *testing.T) {
13 | testCases := []struct {
14 | in string
15 | expected *Config
16 | err string
17 | }{
18 | {
19 | in: "",
20 | err: "s3 bucket is required",
21 | },
22 | {
23 | in: "x",
24 | err: "toml",
25 | },
26 | {
27 | in: `[s3]
28 | bucket = "foo"`,
29 | expected: &Config{
30 | S3: S3Config{
31 | Bucket: "foo",
32 | RequestPresignExpiry: Duration(2 * time.Hour),
33 | },
34 | },
35 | },
36 | {
37 | in: `[s3]
38 | bucket = "foo"
39 | request_presign_expiry = "1h"`,
40 | expected: &Config{
41 | S3: S3Config{
42 | Bucket: "foo",
43 | RequestPresignExpiry: Duration(time.Hour),
44 | },
45 | },
46 | },
47 | }
48 |
49 | for i, tc := range testCases {
50 | t.Run(fmt.Sprintf("case %d", i), func(st *testing.T) {
51 | cfg, err := newConfig(strings.NewReader(tc.in))
52 | if tc.err != "" {
53 | assert.Contains(st, err.Error(), tc.err)
54 | } else {
55 | assert.NoError(st, err)
56 | }
57 | assert.EqualValues(st, tc.expected, cfg)
58 | })
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/docs/directory-browser.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akrylysov/bsimp/010160aa56f8ae3902053912bb85e67aaf8f7595/docs/directory-browser.png
--------------------------------------------------------------------------------
/docs/player.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akrylysov/bsimp/010160aa56f8ae3902053912bb85e67aaf8f7595/docs/player.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/akrylysov/bsimp
2 |
3 | go 1.21
4 |
5 | require (
6 | github.com/aws/aws-sdk-go v1.49.4
7 | github.com/johannesboyne/gofakes3 v0.0.0-20221128113635-c2f5cc6b5294
8 | github.com/pelletier/go-toml/v2 v2.1.1
9 | github.com/stretchr/testify v1.8.4
10 | )
11 |
12 | require (
13 | github.com/davecgh/go-spew v1.1.1 // indirect
14 | github.com/jmespath/go-jmespath v0.4.0 // indirect
15 | github.com/pmezard/go-difflib v1.0.0 // indirect
16 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
17 | github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 // indirect
18 | golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f // indirect
19 | gopkg.in/yaml.v3 v3.0.1 // indirect
20 | )
21 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
2 | github.com/aws/aws-sdk-go v1.49.4 h1:qiXsqEeLLhdLgUIyfr5ot+N/dGPWALmtM1SetRmbUlY=
3 | github.com/aws/aws-sdk-go v1.49.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
8 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
9 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
10 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
11 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
12 | github.com/johannesboyne/gofakes3 v0.0.0-20221128113635-c2f5cc6b5294 h1:AJISYN7tPo3lGqwYmEYQdlftcQz48i8LNk/BRUKCTig=
13 | github.com/johannesboyne/gofakes3 v0.0.0-20221128113635-c2f5cc6b5294/go.mod h1:LIAXxPvcUXwOcTIj9LSNSUpE9/eMHalTWxsP/kmWxQI=
14 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
15 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
18 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
19 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
20 | github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 h1:J6qvD6rbmOil46orKqJaRPG+zTpoGlBTUdyv8ki63L0=
21 | github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63/go.mod h1:n+VKSARF5y/tS9XFSP7vWDfS+GUC5vs/YT7M5XDTUEM=
22 | github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
24 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
25 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
26 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
27 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
28 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
29 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
30 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
31 | go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
32 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
33 | golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
34 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
35 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
36 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
38 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
39 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
40 | golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f h1:SUQ6L9W8e5xt2GFO9s+i18JGITAfem+a0AQuFU8Ls74=
41 | golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4=
42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
44 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
45 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
46 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
47 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
48 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
49 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
50 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
51 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "log/slog"
6 | )
7 |
8 | func main() {
9 | var (
10 | httpAddr string
11 | configPath string
12 | )
13 | flag.StringVar(&httpAddr, "http", ":8080", "HTTP server address")
14 | flag.StringVar(&configPath, "config", "config.toml", "config path")
15 | flag.Parse()
16 |
17 | cfg, err := NewConfig(configPath)
18 | if err != nil {
19 | slog.Error("failed parsing confg", err, slog.String("path", configPath))
20 | return
21 | }
22 |
23 | store, err := NewS3Storage(cfg.S3)
24 | if err != nil {
25 | slog.Error("failed initializing S3 storage", err)
26 | return
27 | }
28 |
29 | mediaLib := NewMediaLibrary(store)
30 |
31 | slog.Info("started HTTP server", slog.String("address", httpAddr))
32 | err = StartServer(mediaLib, httpAddr)
33 | slog.Error("failed starting HTTP server", err)
34 | }
35 |
--------------------------------------------------------------------------------
/media_detector.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "strings"
4 |
5 | type StringSet map[string]struct{}
6 |
7 | func NewStringSet(vs ...string) StringSet {
8 | ss := make(StringSet, len(vs))
9 | for _, v := range vs {
10 | ss.Add(v)
11 | }
12 | return ss
13 | }
14 |
15 | func (ss StringSet) Add(v string) {
16 | ss[v] = struct{}{}
17 | }
18 |
19 | func (ss StringSet) Contains(v string) bool {
20 | _, ok := ss[v]
21 | return ok
22 | }
23 |
24 | // TODO: this should probably live in the config.
25 | var audioExtensions = NewStringSet("mp3", "m4a", "aac", "ogg", "oga", "flac")
26 |
27 | // IsAudioFile returns whether the given file is an audio file.
28 | func IsAudioFile(f *StorageFile) bool {
29 | _, ext := splitNameExt(strings.ToLower(f.Name()))
30 | return audioExtensions.Contains(ext)
31 | }
32 |
33 | var artworkDirNames = NewStringSet("scans", "covers", "artwork", "media")
34 |
35 | // IsArtworkDir returns whether the given directory may contain cover images.
36 | func IsArtworkDir(d *StorageDirectory) bool {
37 | return artworkDirNames.Contains(strings.ToLower(d.Name()))
38 | }
39 |
40 | type ScoredFile struct {
41 | *StorageFile
42 | Score int
43 | }
44 |
45 | var imageExtensions = NewStringSet("jpg", "jpeg", "png", "gif")
46 | var coverNames = NewStringSet("cover", "front", "folder")
47 |
48 | func splitNameExt(fullName string) (string, string) {
49 | idx := strings.LastIndexByte(fullName, '.')
50 | if idx == -1 {
51 | return fullName, ""
52 | }
53 | return fullName[:idx], fullName[idx+1:]
54 | }
55 |
56 | func scoreCover(f *StorageFile) int {
57 | name, ext := splitNameExt(strings.ToLower(f.Name()))
58 | if !imageExtensions.Contains(ext) {
59 | return -1
60 | }
61 | // Exact match.
62 | if coverNames.Contains(name) {
63 | return 2
64 | }
65 | // Partial match.
66 | for pattern := range coverNames {
67 | if strings.Contains(name, pattern) {
68 | return 1
69 | }
70 | }
71 | // Any image.
72 | return 0
73 | }
74 |
75 | // ScoreCovers returns a slice of image files scored as album covers.
76 | // The highest score is more likely to be a cover image.
77 | func ScoreCovers(files []*StorageFile) []ScoredFile {
78 | var scored []ScoredFile
79 | for _, f := range files {
80 | score := scoreCover(f)
81 | if score == -1 {
82 | continue
83 | }
84 | scored = append(scored, ScoredFile{
85 | StorageFile: f,
86 | Score: score,
87 | })
88 | }
89 | return scored
90 | }
91 |
--------------------------------------------------------------------------------
/media_detector_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestScoreCovers(t *testing.T) {
10 | in := files("1.mp3", "2.JpG", "3.GIF", "cover.jpg", "abc", "1_cover.png")
11 | expected := []ScoredFile{
12 | {
13 | StorageFile: in[1],
14 | Score: 0,
15 | },
16 | {
17 | StorageFile: in[2],
18 | Score: 0,
19 | },
20 | {
21 | StorageFile: in[3],
22 | Score: 2,
23 | },
24 | {
25 | StorageFile: in[5],
26 | Score: 1,
27 | },
28 | }
29 | actual := ScoreCovers(in)
30 | assert.EqualValues(t, expected, actual)
31 | }
32 |
33 | func TestIsAudioFile(t *testing.T) {
34 | in := files("1.mp3", "abc", "cover.jpg", "2.ogg", "3.MP3")
35 | expected := []bool{true, false, false, true, true}
36 | for i, f := range in {
37 | actual := IsAudioFile(f)
38 | assert.Equal(t, expected[i], actual)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/media_library.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sort"
5 | )
6 |
7 | type MediaListing struct {
8 | CurrentDirectory *StorageDirectory
9 | Directories []*StorageDirectory
10 | Files []*StorageFile
11 | Cover *StorageFile
12 | AudioTracks []*StorageFile
13 | }
14 |
15 | type MediaLibrary struct {
16 | store *S3Storage
17 | }
18 |
19 | func NewMediaLibrary(store *S3Storage) *MediaLibrary {
20 | return &MediaLibrary{
21 | store: store,
22 | }
23 | }
24 |
25 | func (ml *MediaLibrary) findCover(files []*StorageFile) *StorageFile {
26 | candidates := ScoreCovers(files)
27 | if len(candidates) == 0 {
28 | return nil
29 | }
30 | sort.SliceStable(candidates, func(i, j int) bool {
31 | return candidates[i].Score > candidates[j].Score
32 | })
33 | return candidates[0].StorageFile
34 | }
35 |
36 | func (ml *MediaLibrary) listArtworkFiles(dirs []*StorageDirectory) ([]*StorageFile, error) {
37 | var candidates []*StorageFile
38 | for _, dir := range dirs {
39 | if !IsArtworkDir(dir) {
40 | continue
41 | }
42 | _, files, err := ml.store.List(dir.Path())
43 | if err != nil {
44 | return nil, err
45 | }
46 | candidates = append(candidates, files...)
47 | }
48 | return candidates, nil
49 | }
50 |
51 | // List returns directory listing under the provided path.
52 | func (ml *MediaLibrary) List(p string) (*MediaListing, error) {
53 | dirs, files, err := ml.store.List(p)
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | // Find album cover in the current directory.
59 | cover := ml.findCover(files)
60 |
61 | if cover == nil {
62 | // Scan nested artwork directories for covers.
63 | artworkFiles, err := ml.listArtworkFiles(dirs)
64 | if err != nil {
65 | return nil, err
66 | }
67 | cover = ml.findCover(artworkFiles)
68 | }
69 |
70 | // Find audio tracks and separate all other files.
71 | var tracks []*StorageFile
72 | var otherFiles []*StorageFile
73 | for _, f := range files {
74 | if IsAudioFile(f) {
75 | tracks = append(tracks, f)
76 | } else if cover == nil || f.Path() != cover.Path() {
77 | otherFiles = append(otherFiles, f)
78 | }
79 | }
80 |
81 | listing := &MediaListing{
82 | CurrentDirectory: NewStorageDirectory(p),
83 | Directories: dirs,
84 | Files: otherFiles,
85 | Cover: cover,
86 | AudioTracks: tracks,
87 | }
88 | return listing, nil
89 | }
90 |
91 | // ContentURL returns a public URL to a file under the given path.
92 | func (ml *MediaLibrary) ContentURL(p string) (string, error) {
93 | return ml.store.FileContentURL(p)
94 | }
95 |
--------------------------------------------------------------------------------
/media_library_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/aws/aws-sdk-go/aws"
8 | "github.com/aws/aws-sdk-go/service/s3"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestMediaLibrary(t *testing.T) {
13 | asrt := assert.New(t)
14 |
15 | cfg, closeS3 := newTestS3Config()
16 | defer closeS3()
17 | cfg.BasePrefix = "music/"
18 | storage, err := NewS3Storage(cfg)
19 | asrt.NoError(err)
20 |
21 | _, err = storage.s3.CreateBucket(&s3.CreateBucketInput{
22 | Bucket: aws.String("test"),
23 | })
24 | asrt.NoError(err)
25 |
26 | keys := []string{
27 | "music/Aphex Twin/1992 - Selected Ambient Works 85-92/01. Xtal.mp3",
28 | "music/Aphex Twin/1992 - Selected Ambient Works 85-92/Cover.jpg",
29 | "music/Aphex Twin/1999 - Windowlicker/01 Windowlicker.mp3",
30 | "music/Aphex Twin/1999 - Windowlicker/02 [Equation].mp3",
31 | "music/Aphex Twin/1999 - Windowlicker/03 Nannou.mp3",
32 | "music/Aphex Twin/1999 - Windowlicker/Folder.jpg",
33 | "music/Aphex Twin/1999 - Windowlicker/back.jpg",
34 | "music/Aphex Twin/1999 - Windowlicker/covers/front_cover.jpg",
35 | "music/The Prodigy/1992 - The Prodigy Experience/Scans/Cover-Case.png",
36 | "music/The Prodigy/1992 - The Prodigy Experience/CD1/01 - Jericho.mp3",
37 | "music/The Prodigy/1992 - The Prodigy Experience/CD2/01 - Your Love.mp3",
38 | "music/Venetian Snares/2016 - Traditional Synthesizer Music/01. Dreamt Person v3.mp3",
39 | "music/Venetian Snares/2016 - Traditional Synthesizer Music/tracklist.txt",
40 | }
41 | for _, key := range keys {
42 | _, err := storage.s3.PutObject(&s3.PutObjectInput{
43 | Body: strings.NewReader("1"),
44 | Bucket: aws.String("test"),
45 | Key: aws.String(key),
46 | })
47 | asrt.NoError(err)
48 | }
49 |
50 | testCases := map[string]MediaListing{
51 | "": {
52 | CurrentDirectory: NewStorageDirectory(""),
53 | Directories: []*StorageDirectory{
54 | NewStorageDirectory("Aphex Twin"),
55 | NewStorageDirectory("The Prodigy"),
56 | NewStorageDirectory("Venetian Snares"),
57 | },
58 | },
59 | "Aphex Twin": {
60 | CurrentDirectory: NewStorageDirectory("Aphex Twin"),
61 | Directories: []*StorageDirectory{
62 | NewStorageDirectory("Aphex Twin/1992 - Selected Ambient Works 85-92"),
63 | NewStorageDirectory("Aphex Twin/1999 - Windowlicker"),
64 | },
65 | },
66 | "Aphex Twin/1992 - Selected Ambient Works 85-92": {
67 | CurrentDirectory: NewStorageDirectory("Aphex Twin/1992 - Selected Ambient Works 85-92"),
68 | AudioTracks: []*StorageFile{
69 | NewStorageFile("Aphex Twin/1992 - Selected Ambient Works 85-92/01. Xtal.mp3", 1),
70 | },
71 | Cover: NewStorageFile("Aphex Twin/1992 - Selected Ambient Works 85-92/Cover.jpg", 1),
72 | },
73 | "Aphex Twin/1999 - Windowlicker": {
74 | CurrentDirectory: NewStorageDirectory("Aphex Twin/1999 - Windowlicker"),
75 | AudioTracks: []*StorageFile{
76 | NewStorageFile("Aphex Twin/1999 - Windowlicker/01 Windowlicker.mp3", 1),
77 | NewStorageFile("Aphex Twin/1999 - Windowlicker/02 [Equation].mp3", 1),
78 | NewStorageFile("Aphex Twin/1999 - Windowlicker/03 Nannou.mp3", 1),
79 | },
80 | Cover: NewStorageFile("Aphex Twin/1999 - Windowlicker/Folder.jpg", 1),
81 | Directories: []*StorageDirectory{
82 | NewStorageDirectory("Aphex Twin/1999 - Windowlicker/covers"),
83 | },
84 | Files: []*StorageFile{
85 | NewStorageFile("Aphex Twin/1999 - Windowlicker/back.jpg", 1),
86 | },
87 | },
88 | "The Prodigy": {
89 | CurrentDirectory: NewStorageDirectory("The Prodigy"),
90 | Directories: []*StorageDirectory{
91 | NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience"),
92 | },
93 | },
94 | "The Prodigy/1992 - The Prodigy Experience": {
95 | CurrentDirectory: NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience"),
96 | Directories: []*StorageDirectory{
97 | NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience/CD1"),
98 | NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience/CD2"),
99 | NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience/Scans"),
100 | },
101 | Cover: NewStorageFile("The Prodigy/1992 - The Prodigy Experience/Scans/Cover-Case.png", 1),
102 | },
103 | "The Prodigy/1992 - The Prodigy Experience/CD1": {
104 | CurrentDirectory: NewStorageDirectory("The Prodigy/1992 - The Prodigy Experience/CD1"),
105 | AudioTracks: []*StorageFile{
106 | NewStorageFile("The Prodigy/1992 - The Prodigy Experience/CD1/01 - Jericho.mp3", 1),
107 | },
108 | },
109 | "Venetian Snares": {
110 | CurrentDirectory: NewStorageDirectory("Venetian Snares"),
111 | Directories: []*StorageDirectory{
112 | NewStorageDirectory("Venetian Snares/2016 - Traditional Synthesizer Music"),
113 | },
114 | },
115 | "Venetian Snares/2016 - Traditional Synthesizer Music": {
116 | CurrentDirectory: NewStorageDirectory("Venetian Snares/2016 - Traditional Synthesizer Music"),
117 | AudioTracks: []*StorageFile{
118 | NewStorageFile("Venetian Snares/2016 - Traditional Synthesizer Music/01. Dreamt Person v3.mp3", 1),
119 | },
120 | Files: []*StorageFile{
121 | NewStorageFile("Venetian Snares/2016 - Traditional Synthesizer Music/tracklist.txt", 1),
122 | },
123 | },
124 | }
125 |
126 | ml := NewMediaLibrary(storage)
127 | for path, expectedListing := range testCases {
128 | l, err := ml.List(path)
129 | asrt.NoError(err)
130 | asrt.EqualValues(&expectedListing, l, path)
131 | }
132 |
133 | // Path doesn't exist.
134 | _, err = ml.List("music")
135 | asrt.Error(err)
136 | _, err = ml.List("The Prodigy/1992 - The Prodigy Experience/CD3")
137 | asrt.Error(err)
138 | }
139 |
--------------------------------------------------------------------------------
/server.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "errors"
6 | "fmt"
7 | "html/template"
8 | "io/fs"
9 | "log/slog"
10 | "math/rand"
11 | "net/http"
12 | "strings"
13 | )
14 |
15 | //go:embed templates static
16 | var embedFS embed.FS
17 |
18 | type Server struct {
19 | mediaLib *MediaLibrary
20 | tmpl *template.Template
21 | staticVersion string
22 | }
23 |
24 | func httpError(r *http.Request, w http.ResponseWriter, err error, code int) {
25 | http.Error(w, err.Error(), code)
26 | slog.Error("failed request",
27 | err,
28 | slog.String("url", r.URL.String()),
29 | slog.Int("code", code),
30 | )
31 | }
32 |
33 | // ValidatePath provides a basic protection from the path traversal vulnerability.
34 | func ValidatePath(h http.HandlerFunc) http.HandlerFunc {
35 | return func(w http.ResponseWriter, r *http.Request) {
36 | if strings.Contains(r.URL.Path, "./") || strings.Contains(r.URL.Path, ".\\") {
37 | httpError(r, w, errors.New("invalid path"), http.StatusBadRequest)
38 | return
39 | }
40 | h(w, r)
41 | }
42 | }
43 |
44 | // NormalizePath normalizes the request URL by removing the delimeter suffix.
45 | func NormalizePath(h http.HandlerFunc) http.HandlerFunc {
46 | return func(w http.ResponseWriter, r *http.Request) {
47 | r.URL.Path = strings.TrimRight(r.URL.Path, Delimiter)
48 | h(w, r)
49 | }
50 | }
51 |
52 | // DisableFileListing disables file listing under directories. It can be used with the built-in http.FileServer.
53 | func DisableFileListing(h http.Handler) http.Handler {
54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55 | if strings.HasSuffix(r.URL.Path, "/") {
56 | http.NotFound(w, r)
57 | return
58 | }
59 | h.ServeHTTP(w, r)
60 | })
61 | }
62 |
63 | type TemplateData struct {
64 | StaticVersion string
65 | *MediaListing
66 | }
67 |
68 | func (s *Server) ListingHandler(w http.ResponseWriter, r *http.Request) {
69 | listing, err := s.mediaLib.List(r.URL.Path)
70 | if err != nil {
71 | httpError(r, w, err, http.StatusInternalServerError)
72 | return
73 | }
74 | tmplData := TemplateData{
75 | StaticVersion: s.staticVersion,
76 | MediaListing: listing,
77 | }
78 | if err := s.tmpl.ExecuteTemplate(w, "listing.gohtml", tmplData); err != nil {
79 | httpError(r, w, err, http.StatusInternalServerError)
80 | return
81 | }
82 | }
83 |
84 | func (s *Server) StreamHandler(w http.ResponseWriter, r *http.Request) {
85 | url, err := s.mediaLib.ContentURL(r.URL.Path)
86 | if err != nil {
87 | httpError(r, w, err, http.StatusInternalServerError)
88 | return
89 | }
90 | http.Redirect(w, r, url, http.StatusFound)
91 | }
92 |
93 | // Don't include sprig just for one function.
94 | var templateFunctions = map[string]any{
95 | "defaultString": func(s string, def string) string {
96 | if s == "" {
97 | return def
98 | }
99 | return s
100 | },
101 | }
102 |
103 | // StartServer starts HTTP server.
104 | func StartServer(mediaLib *MediaLibrary, addr string) error {
105 | tmpl, err := template.New("").Funcs(templateFunctions).ParseFS(embedFS, "templates/*.gohtml")
106 | if err != nil {
107 | return err
108 | }
109 |
110 | mux := http.NewServeMux()
111 |
112 | mux.Handle("/", http.RedirectHandler("/library/", http.StatusMovedPermanently))
113 |
114 | staticVersion := fmt.Sprintf("%x", rand.Uint64())
115 | staticFS, err := fs.Sub(embedFS, "static")
116 | if err != nil {
117 | return err
118 | }
119 | staticPath := fmt.Sprintf("/static/%s/", staticVersion)
120 | mux.Handle(staticPath, DisableFileListing(http.StripPrefix(staticPath, http.FileServer(http.FS(staticFS)))))
121 |
122 | s := Server{
123 | mediaLib: mediaLib,
124 | tmpl: tmpl,
125 | staticVersion: staticVersion,
126 | }
127 | mux.Handle("/library/", http.StripPrefix("/library/", ValidatePath(NormalizePath(s.ListingHandler))))
128 | mux.Handle("/stream/", http.StripPrefix("/stream/", ValidatePath(NormalizePath(s.StreamHandler))))
129 |
130 | return http.ListenAndServe(addr, mux)
131 | }
132 |
--------------------------------------------------------------------------------
/static/document.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/folder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/play-skip-back.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/play-skip-forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/player.js:
--------------------------------------------------------------------------------
1 | function fmtTime(s) {
2 | const d = new Date(s * 1000);
3 | if (s > 600) {
4 | return d.toISOString().slice(11, 19);
5 | }
6 | return d.toISOString().slice(14, 19);
7 | }
8 |
9 | function initPlayer() {
10 | const titleEl = document.querySelector(".title");
11 | const buttonPlayPauseEl = document.querySelector(".button-playpause");
12 | const progressEl = document.querySelector("input[type='range']");
13 | const timeElapsedEl = document.querySelector(".time-elapsed");
14 | const timeTotalEl = document.querySelector(".time-total");
15 | const buttonPrevEl = document.querySelector(".button-prev");
16 | const buttonNextEl = document.querySelector(".button-next");
17 | const coverImgEl = document.querySelector(".cover > img");
18 | const trackEls = document.querySelectorAll(".track");
19 | if (trackEls.length == 0) {
20 | return;
21 | }
22 | var currentTrackIdx = 0;
23 |
24 | if (trackEls.length > 1) {
25 | buttonNextEl.classList.remove("disabled");
26 | }
27 |
28 | const audio = new Audio();
29 |
30 | function setTrack(idx) {
31 | currentTrackIdx = idx;
32 | const trackEl = trackEls[idx];
33 | audio.src = trackEl.dataset.url;
34 | titleEl.innerText = trackEl.dataset.title;
35 |
36 | if (idx == 0) {
37 | buttonPrevEl.classList.add("disabled");
38 | } else {
39 | buttonPrevEl.classList.remove("disabled");
40 | }
41 |
42 | if (idx == trackEls.length - 1) {
43 | buttonNextEl.classList.add("disabled");
44 | } else {
45 | buttonNextEl.classList.remove("disabled");
46 | }
47 |
48 | if ('mediaSession' in navigator) {
49 | let meta = {
50 | title: trackEl.dataset.title,
51 | artist: "",
52 | album: ""
53 | };
54 | if (coverImgEl) {
55 | meta.artwork = [{ src: coverImgEl.src }]
56 | }
57 | navigator.mediaSession.metadata = new MediaMetadata(meta);
58 | }
59 | }
60 |
61 | function play() {
62 | audio.play();
63 | buttonPlayPauseEl.classList.add("playing");
64 | trackEls[currentTrackIdx].classList.add("playing");
65 | }
66 |
67 | function pause() {
68 | audio.pause();
69 | buttonPlayPauseEl.classList.remove("playing");
70 | trackEls[currentTrackIdx].classList.remove("playing");
71 | }
72 |
73 | setTrack(0);
74 |
75 | let mouseDownOnSlider = false;
76 |
77 | audio.addEventListener("loadeddata", () => {
78 | progressEl.value = 0;
79 | });
80 | audio.addEventListener("timeupdate", () => {
81 | if (mouseDownOnSlider || !audio.duration) {
82 | return;
83 | }
84 | progressEl.value = audio.currentTime / audio.duration * 100;
85 | timeElapsedEl.textContent = fmtTime(audio.currentTime);
86 | timeTotalEl.textContent = fmtTime(audio.duration);
87 | });
88 | audio.addEventListener("ended", () => {
89 | pause();
90 | if (currentTrackIdx < trackEls.length - 1) {
91 | setTrack(currentTrackIdx + 1);
92 | play();
93 | }
94 | });
95 | audio.addEventListener("pause", () => {
96 | buttonPlayPauseEl.classList.remove("playing");
97 | trackEls[currentTrackIdx].classList.remove("playing");
98 | });
99 | audio.addEventListener("play", () => {
100 | buttonPlayPauseEl.classList.add("playing");
101 | trackEls[currentTrackIdx].classList.add("playing");
102 | });
103 |
104 | buttonPlayPauseEl.addEventListener("click", () => {
105 | if (audio.paused) {
106 | play();
107 | } else {
108 | pause();
109 | }
110 | });
111 |
112 | progressEl.addEventListener("change", () => {
113 | const pct = progressEl.value / 100;
114 | audio.currentTime = (audio.duration || 0) * pct;
115 | });
116 | progressEl.addEventListener("mousedown", () => {
117 | mouseDownOnSlider = true;
118 | });
119 | progressEl.addEventListener("mouseup", () => {
120 | mouseDownOnSlider = false;
121 | });
122 |
123 |
124 | function prev() {
125 | if (buttonPrevEl.classList.contains("disabled")) {
126 | return;
127 | }
128 | pause();
129 | setTrack(currentTrackIdx - 1);
130 | play();
131 | }
132 |
133 | function next() {
134 | if (buttonNextEl.classList.contains("disabled")) {
135 | return;
136 | }
137 | pause();
138 | setTrack(currentTrackIdx + 1);
139 | play();
140 | }
141 |
142 | buttonPrevEl.addEventListener("click", prev);
143 | buttonNextEl.addEventListener("click", next);
144 |
145 | if ('mediaSession' in navigator) {
146 | // mediaSession is flaky in Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=1337536
147 | navigator.mediaSession.setActionHandler('previoustrack', prev);
148 | navigator.mediaSession.setActionHandler('nexttrack', next);
149 | }
150 |
151 | trackEls.forEach(el => el.addEventListener("click", event => {
152 | const trackEl = event.currentTarget;
153 | const targetIdx = parseInt(trackEl.dataset.index, 10);
154 | if (targetIdx == currentTrackIdx) {
155 | if (audio.paused) {
156 | audio.play();
157 | } else {
158 | audio.pause();
159 | }
160 | return;
161 | }
162 | pause();
163 | setTrack(targetIdx);
164 | play();
165 | }));
166 | }
167 |
168 | window.addEventListener("DOMContentLoaded", initPlayer);
169 |
--------------------------------------------------------------------------------
/static/style.css:
--------------------------------------------------------------------------------
1 | /* Global */
2 | body {
3 | font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
4 | min-width: 22rem;
5 | font-size: 1.125rem;
6 | }
7 |
8 | @media only screen and (min-width: 768px) {
9 | body {
10 | max-width: 38rem;
11 | font-size: 1rem;
12 | }
13 | }
14 |
15 | /* Directory listing and playlist tables */
16 | .table {
17 | margin: 1.125rem 0 0 0;
18 | border-top: thin solid grey;
19 | }
20 |
21 | .row {
22 | border-bottom: thin solid grey;
23 | padding: 0.625rem 0 0.563rem;
24 | display: block;
25 | color: inherit;
26 | text-decoration: inherit;
27 | cursor: pointer;
28 | }
29 |
30 | .icon {
31 | padding-left: 2rem;
32 | background: center/1rem no-repeat;
33 | }
34 |
35 | .icon.folder {
36 | background-image: url("folder.svg");
37 | }
38 |
39 | .icon.file {
40 | background-image: url("document.svg");
41 | }
42 |
43 | .track>.icon.button-track-playpause {
44 | background-image: url("play.svg");
45 | }
46 |
47 | .track.playing>.icon.button-track-playpause {
48 | background-image: url("pause.svg");
49 | }
50 |
51 | /* Cover */
52 | .cover {
53 | display: flex;
54 | justify-content: center;
55 | align-items: center;
56 | margin-top: 1.25rem;
57 | }
58 |
59 | .cover>img {
60 | width: 22rem;
61 | max-width: 100%;
62 | }
63 |
64 | /* Title */
65 | .title {
66 | display: flex;
67 | justify-content: center;
68 | align-items: center;
69 | margin-top: 1.25rem;
70 | }
71 |
72 | /* Main player controls */
73 | .controls {
74 | display: flex;
75 | align-items: center;
76 | }
77 |
78 | .time-elapsed,
79 | .time-total {
80 | padding: 0 0.25rem 0 0.25rem;
81 | }
82 |
83 | .progressbar {
84 | flex-grow: 1;
85 | }
86 |
87 | .button-playpause {
88 | width: 3rem;
89 | height: 3rem;
90 | display: inline-block;
91 | vertical-align: middle;
92 | background: url("play.svg") center/3rem no-repeat;
93 | }
94 |
95 | .button-playpause.playing {
96 | background: url("pause.svg") center/3rem no-repeat;
97 | }
98 |
99 | .button-prev,
100 | .button-next {
101 | width: 2rem;
102 | height: 2rem;
103 | display: inline-block;
104 | vertical-align: middle;
105 | }
106 |
107 | .button-prev {
108 | background: url("play-skip-back.svg") center/1.5rem no-repeat;
109 | }
110 |
111 | .button-next {
112 | background: url("play-skip-forward.svg") center/1.5rem no-repeat;
113 | }
114 |
115 | span[class^="button-"] {
116 | cursor: pointer;
117 | }
118 |
119 | span[class^="button-"].disabled {
120 | opacity: .4;
121 | cursor: unset;
122 | }
123 |
--------------------------------------------------------------------------------
/storage.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "path"
6 | "strings"
7 | "time"
8 |
9 | "github.com/aws/aws-sdk-go/aws"
10 | "github.com/aws/aws-sdk-go/aws/credentials"
11 | "github.com/aws/aws-sdk-go/aws/session"
12 | "github.com/aws/aws-sdk-go/service/s3"
13 | )
14 |
15 | type storageEntry struct {
16 | path string
17 | }
18 |
19 | func (e *storageEntry) Path() string {
20 | return e.path
21 | }
22 |
23 | func (e *storageEntry) Name() string {
24 | _, file := path.Split(e.path)
25 | return file
26 | }
27 |
28 | func (e *storageEntry) String() string {
29 | return e.Name()
30 | }
31 |
32 | type StorageDirectory struct {
33 | storageEntry
34 | }
35 |
36 | func NewStorageDirectory(p string) *StorageDirectory {
37 | return &StorageDirectory{
38 | storageEntry{
39 | path: p,
40 | },
41 | }
42 | }
43 |
44 | func ReverseSlice[T any](s []T) {
45 | i := 0
46 | j := len(s) - 1
47 | for i < j {
48 | s[i], s[j] = s[j], s[i]
49 | i += 1
50 | j -= 1
51 | }
52 | }
53 |
54 | // Parents return a slice of all parent directories from the root.
55 | // E.g. it returns [/, /a, /a/b] for /a/b.
56 | func (e *StorageDirectory) Parents() []*StorageDirectory {
57 | if e.path == "" {
58 | // The root directory doesn't have any parents.
59 | return nil
60 | }
61 | var dirs []*StorageDirectory
62 | p := e.path
63 | for idx := strings.LastIndexByte(p, '/'); idx != -1; idx = strings.LastIndexByte(p, '/') {
64 | p = p[:idx]
65 | dirs = append(dirs, NewStorageDirectory(p))
66 | }
67 |
68 | // Append root directory.
69 | dirs = append(dirs, NewStorageDirectory(""))
70 |
71 | ReverseSlice(dirs)
72 |
73 | return dirs
74 | }
75 |
76 | type StorageFile struct {
77 | storageEntry
78 | Size int64
79 | }
80 |
81 | func NewStorageFile(p string, size int64) *StorageFile {
82 | return &StorageFile{
83 | storageEntry: storageEntry{
84 | path: p,
85 | },
86 | Size: size,
87 | }
88 | }
89 |
90 | // FriendlyName returns a user-friendly file name. The implementation just returns the name without extension.
91 | func (e *StorageFile) FriendlyName() string {
92 | name, _ := splitNameExt(e.Name())
93 | return name
94 | }
95 |
96 | type S3Storage struct {
97 | s3 *s3.S3
98 | cfg S3Config
99 | }
100 |
101 | func NewS3Storage(cfg S3Config) (*S3Storage, error) {
102 | awsConfig := aws.Config{
103 | Region: cfg.Region,
104 | Endpoint: cfg.Endpoint,
105 | }
106 | if cfg.Credentials != nil {
107 | awsConfig.Credentials = credentials.NewStaticCredentials(cfg.Credentials.ID, cfg.Credentials.Secret, cfg.Credentials.Token)
108 | }
109 | if cfg.ForcePathStyle {
110 | awsConfig.S3ForcePathStyle = aws.Bool(true)
111 | }
112 | sess, err := session.NewSession(&awsConfig)
113 | if err != nil {
114 | return nil, err
115 | }
116 | store := S3Storage{
117 | s3: s3.New(sess),
118 | cfg: cfg,
119 | }
120 | return &store, nil
121 | }
122 |
123 | // prefix returns an S3 prefix from a public user-provided path.
124 | // prefix can be the entire key.
125 | func (store *S3Storage) prefix(p string) string {
126 | prefix := path.Join(store.cfg.BasePrefix, p)
127 | return prefix
128 | }
129 |
130 | // path returns a public path exposed to the user from an internal S3 key.
131 | func (store *S3Storage) path(key string) string {
132 | return strings.TrimRight(
133 | strings.TrimPrefix(key, store.cfg.BasePrefix),
134 | Delimiter,
135 | )
136 | }
137 |
138 | // List returns slices of directories and files under the given path.
139 | func (store *S3Storage) List(p string) ([]*StorageDirectory, []*StorageFile, error) {
140 | input := &s3.ListObjectsV2Input{
141 | Bucket: aws.String(store.cfg.Bucket),
142 | Delimiter: aws.String(Delimiter),
143 | }
144 | prefix := store.prefix(p)
145 | if prefix != "" {
146 | input.Prefix = aws.String(prefix + Delimiter)
147 | }
148 |
149 | var prefixes []*s3.CommonPrefix
150 | var objects []*s3.Object
151 | err := store.s3.ListObjectsV2Pages(input, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
152 | prefixes = append(prefixes, page.CommonPrefixes...)
153 | for _, object := range page.Contents {
154 | // Ignore empty objects used to emulate empty directories.
155 | if *object.Size != 0 {
156 | objects = append(objects, object)
157 | }
158 | }
159 | return true
160 | })
161 | if err != nil {
162 | return nil, nil, err
163 | }
164 |
165 | if len(prefixes) == 0 && len(objects) == 0 {
166 | return nil, nil, errors.New("directory doesn't exist")
167 | }
168 |
169 | var dirs []*StorageDirectory
170 | var files []*StorageFile
171 |
172 | for _, prefix := range prefixes {
173 | dirs = append(dirs, NewStorageDirectory(store.path(*prefix.Prefix)))
174 | }
175 |
176 | for _, object := range objects {
177 | files = append(files, NewStorageFile(store.path(*object.Key), *object.Size))
178 | }
179 |
180 | return dirs, files, nil
181 | }
182 |
183 | // FileSize returns size of the file under the given path.
184 | func (store *S3Storage) FileSize(p string) (int64, error) {
185 | input := &s3.HeadObjectInput{
186 | Bucket: aws.String(store.cfg.Bucket),
187 | Key: aws.String(store.prefix(p)),
188 | }
189 | resp, err := store.s3.HeadObject(input)
190 | if err != nil {
191 | return 0, err
192 | }
193 | return *resp.ContentLength, nil
194 | }
195 |
196 | // FileContentURL returns a publicly accessible URL for the file under the given path.
197 | func (store *S3Storage) FileContentURL(p string) (string, error) {
198 | size, err := store.FileSize(p)
199 | if err != nil {
200 | return "", err
201 | }
202 | if size == 0 {
203 | return "", errors.New("no content")
204 | }
205 | req, _ := store.s3.GetObjectRequest(&s3.GetObjectInput{
206 | Bucket: aws.String(store.cfg.Bucket),
207 | Key: aws.String(store.prefix(p)),
208 | })
209 | return req.Presign(time.Duration(store.cfg.RequestPresignExpiry))
210 | }
211 |
--------------------------------------------------------------------------------
/storage_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http/httptest"
5 | "strings"
6 | "testing"
7 | "time"
8 |
9 | "github.com/aws/aws-sdk-go/aws"
10 | "github.com/aws/aws-sdk-go/service/s3"
11 | "github.com/johannesboyne/gofakes3"
12 | "github.com/johannesboyne/gofakes3/backend/s3mem"
13 | "github.com/stretchr/testify/assert"
14 | )
15 |
16 | func files(paths ...string) []*StorageFile {
17 | var files []*StorageFile
18 | for _, p := range paths {
19 | files = append(files, NewStorageFile(p, 1))
20 | }
21 | return files
22 | }
23 |
24 | func dirs(paths ...string) []*StorageDirectory {
25 | var dirs []*StorageDirectory
26 | for _, p := range paths {
27 | dirs = append(dirs, NewStorageDirectory(p))
28 | }
29 | return dirs
30 | }
31 |
32 | func TestStorageDirectory_Parents(t *testing.T) {
33 | testCases := []struct {
34 | p string
35 | expected []*StorageDirectory
36 | }{
37 | {
38 | p: "",
39 | expected: dirs(),
40 | },
41 | {
42 | p: "a",
43 | expected: dirs(""),
44 | },
45 | {
46 | p: "a/b",
47 | expected: dirs("", "a"),
48 | },
49 | {
50 | p: "a/b/c",
51 | expected: dirs("", "a", "a/b"),
52 | },
53 | }
54 | for _, tc := range testCases {
55 | dir := NewStorageDirectory(tc.p)
56 | assert.EqualValues(t, tc.expected, dir.Parents())
57 | }
58 | }
59 |
60 | func newTestS3Config() (S3Config, func()) {
61 | backend := s3mem.New()
62 | faker := gofakes3.New(backend)
63 | ts := httptest.NewServer(faker.Server())
64 |
65 | region := "test"
66 | return S3Config{
67 | Region: ®ion,
68 | Endpoint: &ts.URL,
69 | Bucket: "test",
70 | ForcePathStyle: true,
71 | Credentials: &S3Credentials{
72 | ID: "id1",
73 | Secret: "secret1",
74 | },
75 | RequestPresignExpiry: Duration(time.Minute),
76 | }, ts.Close
77 | }
78 |
79 | func TestS3Storage(t *testing.T) {
80 | asrt := assert.New(t)
81 |
82 | cfg, closeS3 := newTestS3Config()
83 | defer closeS3()
84 | s, err := NewS3Storage(cfg)
85 | asrt.NoError(err)
86 |
87 | put := func(path, content string) {
88 | t.Helper()
89 | _, err := s.s3.PutObject(&s3.PutObjectInput{
90 | Body: strings.NewReader(content),
91 | Bucket: aws.String("test"),
92 | Key: aws.String(path),
93 | })
94 | asrt.NoError(err)
95 | }
96 |
97 | // Bucket doesn't exist.
98 | _, _, err = s.List("")
99 | asrt.Error(err)
100 |
101 | // Bucket exists, but has no content.
102 | _, err = s.s3.CreateBucket(&s3.CreateBucketInput{
103 | Bucket: aws.String("test"),
104 | })
105 | asrt.NoError(err)
106 |
107 | _, _, err = s.List("")
108 | asrt.Error(err)
109 |
110 | // Single file.
111 | put("file1.jpg", "1")
112 | put("empty", "") // Empty files should be ignored.
113 | dirs, files, err := s.List("")
114 | asrt.NoError(err)
115 | asrt.Empty(dirs)
116 | asrt.Len(files, 1)
117 | asrt.Equal("file1.jpg", files[0].path)
118 |
119 | // Single directory.
120 | put("dir1/file2.jpg", "12")
121 | put("dir1/empty", "")
122 | dirs, files, err = s.List("")
123 | asrt.NoError(err)
124 | asrt.Len(dirs, 1)
125 | asrt.Equal("dir1", dirs[0].path)
126 | asrt.Len(dirs[0].Parents(), 1)
127 | asrt.Equal("", dirs[0].Parents()[0].path)
128 | asrt.Len(files, 1)
129 | asrt.Equal("file1.jpg", files[0].path)
130 |
131 | // Two directories.
132 | put("dir2/file3.jpg", "123")
133 | dirs, files, err = s.List("")
134 | asrt.NoError(err)
135 | asrt.Len(dirs, 2)
136 | asrt.Equal("dir1", dirs[0].path)
137 | asrt.Equal("dir2", dirs[1].path)
138 | asrt.Len(files, 1)
139 | asrt.Equal("file1.jpg", files[0].path)
140 |
141 | // Nested directories.
142 | put("dir2/dir22/file4.jpg", "1234")
143 | dirs, files, err = s.List("")
144 | asrt.NoError(err)
145 | asrt.Len(dirs, 2)
146 | asrt.Equal("dir1", dirs[0].path)
147 | asrt.Equal("dir2", dirs[1].path)
148 | asrt.Len(files, 1)
149 | asrt.Equal("file1.jpg", files[0].path)
150 |
151 | dirs, files, err = s.List("dir1")
152 | asrt.NoError(err)
153 | asrt.Empty(dirs)
154 | asrt.Len(files, 1)
155 | asrt.Equal("dir1/file2.jpg", files[0].path)
156 | asrt.Equal("file2.jpg", files[0].Name())
157 | asrt.Equal("file2", files[0].FriendlyName())
158 |
159 | dirs, files, err = s.List("dir2")
160 | asrt.NoError(err)
161 | asrt.Len(dirs, 1)
162 | asrt.Equal("dir2/dir22", dirs[0].path)
163 | asrt.Equal("dir22", dirs[0].Name())
164 | asrt.Len(dirs[0].Parents(), 2)
165 | asrt.Equal("", dirs[0].Parents()[0].path)
166 | asrt.Equal("dir2", dirs[0].Parents()[1].path)
167 | asrt.Len(files, 1)
168 | asrt.Equal("dir2/file3.jpg", files[0].path)
169 |
170 | dirs, files, err = s.List("dir2/dir22")
171 | asrt.NoError(err)
172 | asrt.Empty(dirs)
173 | asrt.Len(files, 1)
174 | asrt.Equal("dir2/dir22/file4.jpg", files[0].path)
175 |
176 | // Prefix doexn't exist.
177 | _, _, err = s.List("dir3")
178 | asrt.Error(err)
179 |
180 | _, _, err = s.List("dir2/dir23")
181 | asrt.Error(err)
182 |
183 | // Content URL.
184 | url, err := s.FileContentURL("file1.jpg")
185 | asrt.NoError(err)
186 | asrt.NotEmpty(url)
187 |
188 | url, err = s.FileContentURL("dir2/dir22/file4.jpg")
189 | asrt.NoError(err)
190 | asrt.NotEmpty(url)
191 |
192 | url, err = s.FileContentURL("dir2/dir22/file5.jpg")
193 | asrt.Error(err)
194 | asrt.Empty(url)
195 |
196 | // File size.
197 | size, err := s.FileSize("file1.jpg")
198 | asrt.NoError(err)
199 | asrt.EqualValues(1, size)
200 |
201 | size, err = s.FileSize("dir2/dir22/file4.jpg")
202 | asrt.NoError(err)
203 | asrt.EqualValues(4, size)
204 |
205 | _, err = s.FileSize("dir2/dir22/file5.jpg")
206 | asrt.Error(err)
207 |
208 | // Base prefix dir1.
209 | s.cfg.BasePrefix = "dir1/"
210 | dirs, files, err = s.List("")
211 | asrt.NoError(err)
212 | asrt.Empty(dirs)
213 | asrt.Len(files, 1)
214 | asrt.Equal("file2.jpg", files[0].path)
215 |
216 | // Base prefix dir2.
217 | s.cfg.BasePrefix = "dir2/"
218 | dirs, files, err = s.List("")
219 | asrt.NoError(err)
220 | asrt.Len(dirs, 1)
221 | asrt.Equal("dir22", dirs[0].path)
222 | asrt.Len(dirs[0].Parents(), 1)
223 | asrt.Equal("", dirs[0].Parents()[0].path)
224 | asrt.Len(files, 1)
225 | asrt.Equal("file3.jpg", files[0].path)
226 |
227 | dirs, files, err = s.List("dir22")
228 | asrt.NoError(err)
229 | asrt.Empty(dirs)
230 | asrt.Len(files, 1)
231 | asrt.Equal("dir22/file4.jpg", files[0].path)
232 |
233 | // Base prefix doesn't exist.
234 | s.cfg.BasePrefix = "dir3/"
235 | _, _, err = s.List("")
236 | asrt.Error(err)
237 | }
238 |
--------------------------------------------------------------------------------
/templates/listing.gohtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |