├── .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 | directory-browser 24 | 25 | Audio Player 26 | 27 | player 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 | ionicons-v5-e -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/folder.svg: -------------------------------------------------------------------------------- 1 | ionicons-v5-n -------------------------------------------------------------------------------- /static/pause.svg: -------------------------------------------------------------------------------- 1 | ionicons-v5-c -------------------------------------------------------------------------------- /static/play-skip-back.svg: -------------------------------------------------------------------------------- 1 | ionicons-v5-c -------------------------------------------------------------------------------- /static/play-skip-forward.svg: -------------------------------------------------------------------------------- 1 | ionicons-v5-c -------------------------------------------------------------------------------- /static/play.svg: -------------------------------------------------------------------------------- 1 | ionicons-v5-c -------------------------------------------------------------------------------- /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 | 5 | 6 | {{ .CurrentDirectory.Name }} 7 | {{ if .Cover }} 8 | 9 | {{ else }} 10 | 11 | {{ end }} 12 | {{/* SVG icons used in the stylesheet https://github.com/ionic-team/ionicons */}} 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | {{ range $dir := .CurrentDirectory.Parents }} 21 | {{ defaultString $dir.Name "Music" }} / 22 | {{ end }} 23 | {{ defaultString .CurrentDirectory.Name "Music" }} 24 |
25 | 26 | {{ if .Cover }} 27 |
28 | Cover 29 |
30 | {{ end }} 31 | 32 | {{ if .AudioTracks }} 33 |
34 | 35 |
36 | 37 | 00:00 38 | 39 | 00:00 40 | 41 | 42 |
43 | {{ end }} 44 | 45 | {{ if or .AudioTracks (or .Files .Directories) }} 46 |
47 | {{ range $index, $track := .AudioTracks }} 48 |
50 | 51 | {{ $track.FriendlyName}} 52 |
53 | {{ end }} 54 | {{ range $dir := .Directories }} 55 | 56 | 57 | {{ $dir.Name }} 58 | 59 | {{ end }} 60 | {{ range $file := .Files }} 61 | 62 | 63 | {{ $file.Name }} 64 | 65 | {{ end }} 66 |
67 | {{ end }} 68 | 69 | 70 | 71 | 72 | --------------------------------------------------------------------------------