├── assets └── templates │ ├── music_artists.gohtml │ ├── _nav.gohtml │ ├── _style-default.gohtml │ ├── _quota.gohtml │ ├── _head.gohtml │ ├── forgot-sent.gohtml │ ├── _style-plain.gohtml │ ├── playlist_tracks.gohtml │ ├── _foot.gohtml │ ├── checkout-unpaid.gohtml │ ├── _nav-out.gohtml │ ├── checkout.gohtml │ ├── _track-name.gohtml │ ├── admin.gohtml │ ├── forgot.gohtml │ ├── _style-groove.gohtml │ ├── _nav-in.gohtml │ ├── _style-spooky.gohtml │ ├── recover.gohtml │ ├── more.gohtml │ ├── settings-password.gohtml │ ├── music_albums.gohtml │ ├── login.gohtml │ ├── register.gohtml │ ├── privacy.gohtml │ ├── music_all.gohtml │ ├── _style.gohtml │ ├── terms.gohtml │ ├── index.gohtml │ ├── track-edit.gohtml │ ├── buy.gohtml │ ├── subsonic.gohtml │ └── settings.gohtml ├── cmd └── tubesync │ ├── README.md │ ├── go.mod │ ├── go.sum │ └── main.go ├── lambda_none.go ├── .gitignore ├── event ├── lambda.go ├── sqs.go └── dynamodb.go ├── lambda.go ├── deploy ├── build.sh └── deploy.sh ├── web ├── error.go ├── admin.go ├── migrate.go ├── metadata_test.go ├── static.go ├── sync.go ├── buy.go ├── render.go ├── apiv0.go ├── i18n.go ├── playlist.go ├── context.go ├── api.go ├── expr.go ├── settings.go ├── subsonic-album.go ├── subsonic-artist.go ├── template.go ├── subsonic-playlist.go ├── upload.go ├── metadata.go └── file.go ├── storage ├── retry.go ├── sqs.go └── s3.go ├── tube ├── star.go ├── event.go ├── session.go ├── id.go ├── plan.go ├── db.go ├── playlist.go ├── dump.go └── file.go ├── docker-compose.yml ├── LICENSE ├── email └── mail.go ├── config.go ├── config.example.toml ├── go.mod ├── README.md └── main.go /assets/templates/music_artists.gohtml: -------------------------------------------------------------------------------- 1 |
TODO: artists view
-------------------------------------------------------------------------------- /cmd/tubesync/README.md: -------------------------------------------------------------------------------- 1 | # tubesync 2 | 3 | This is a simple CLI program for downloading your entire music library. 4 | -------------------------------------------------------------------------------- /assets/templates/_nav.gohtml: -------------------------------------------------------------------------------- 1 | {{if loggedin}} 2 | {{render "_nav-in" $}} 3 | {{else}} 4 | {{render "_nav-out" $}} 5 | {{end}} 6 | -------------------------------------------------------------------------------- /assets/templates/_style-default.gohtml: -------------------------------------------------------------------------------- 1 | {{stylesheet "plain" $}} 2 | @media (prefers-color-scheme: dark) { 3 | {{stylesheet "groove" $}} 4 | } -------------------------------------------------------------------------------- /lambda_none.go: -------------------------------------------------------------------------------- 1 | //go:build !lambda 2 | 3 | package main 4 | 5 | func startLambda() { 6 | panic("lambda disabled") 7 | } 8 | 9 | func startEventLambda(mode string) { 10 | panic("lambda disabled") 11 | } 12 | -------------------------------------------------------------------------------- /assets/templates/_quota.gohtml: -------------------------------------------------------------------------------- 1 | {{$quota := $.User.CalcQuota}} 2 | {{$.User.UsageDesc}}% -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | intertube.exe 2 | intertube 3 | intertube.zip 4 | main 5 | env.sh 6 | env.ps1 7 | cmd/tubesync/tubesync.exe 8 | cmd/tubesync/tubesync 9 | deploydate 10 | */node_modules 11 | config.toml 12 | .DS_Store 13 | bootstrap 14 | -------------------------------------------------------------------------------- /assets/templates/_head.gohtml: -------------------------------------------------------------------------------- 1 | 2 | {{render "_style" $}} 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/templates/forgot-sent.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "forgot_title"}} 6 | 7 | 8 |
9 |

{{tr "forgot_title"}}

10 |

{{tr "forgot_sent" $.Email}}

11 |
12 | 13 | -------------------------------------------------------------------------------- /event/lambda.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/lambda" 5 | ) 6 | 7 | func StartLambda(mode string) { 8 | switch mode { 9 | case "CHANGE": 10 | lambda.Start(handleChange) 11 | case "FILE": 12 | lambda.Start(handleFileQueue) 13 | } 14 | panic("unhandled mode: " + mode) 15 | } 16 | -------------------------------------------------------------------------------- /assets/templates/_style-plain.gohtml: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: white; 3 | --fg: black; 4 | --hi-bg: #f7f7f7; 5 | --hi-fg: black; 6 | --button-bg: #efefef; 7 | --links: blue; 8 | --nav-bg: white; 9 | --nav-border: lightgray; 10 | --border: lightgray; 11 | --frost-bg: #f7f7f7b3; 12 | --frost-fallback: #f7f7f7f0; 13 | --frost-filter: blur(6px); 14 | --logo: black; 15 | } -------------------------------------------------------------------------------- /lambda.go: -------------------------------------------------------------------------------- 1 | //go:build lambda 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/akrylysov/algnhsa" 9 | "github.com/guregu/intertube/event" 10 | // "github.com/aws/aws-lambda-go/lambda" 11 | ) 12 | 13 | func startLambda() { 14 | algnhsa.ListenAndServe(http.DefaultServeMux, nil) 15 | } 16 | 17 | func startEventLambda(mode string) { 18 | event.StartLambda(mode) 19 | } 20 | -------------------------------------------------------------------------------- /assets/templates/playlist_tracks.gohtml: -------------------------------------------------------------------------------- 1 |
2 |

results

3 | {{with $.Query}} 4 |

query: {{.}}

5 | {{else}} 6 |

searcjasiodjosia

7 | {{end}} 8 |

{{len $.Tracks}} tracks

9 |
    10 | {{range $.Tracks}} 11 |
  1. 12 | {{template "_track-name.gohtml" .}} 13 |
  2. 14 | {{end}} 15 |
16 |
-------------------------------------------------------------------------------- /assets/templates/_foot.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deploy/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | APP_NAME=intertube 3 | 4 | TAR_NAME=${APP_NAME}.zip 5 | 6 | # clean up dist directory 7 | if [ -f "${TAR_NAME}" ]; then 8 | rm ${TAR_NAME} deploydate 9 | fi 10 | 11 | date +%s > deploydate 12 | 13 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main -tags lambda 14 | cp main bootstrap 15 | zip ${TAR_NAME} main 16 | zip ${TAR_NAME} bootstrap 17 | zip -ur ${TAR_NAME} assets 18 | zip -u ${TAR_NAME} deploydate config.toml 19 | -------------------------------------------------------------------------------- /assets/templates/checkout-unpaid.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "checkout_fail"}} 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

{{tr "checkout_fail"}}

11 |

{{tr "checkout_failexplain"}}

12 |

{{tr "checkout_status"}}: {{$.Status}}

13 |

{{tr "checkout_tryagain"}}

14 |
15 | 16 | -------------------------------------------------------------------------------- /assets/templates/_nav-out.gohtml: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /web/error.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "runtime/debug" 8 | 9 | "github.com/guregu/kami" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | func PanicHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { 14 | if isSubsonicReq(r) { 15 | subsonicPanicHandler(ctx, w, r) 16 | return 17 | } 18 | 19 | ex := kami.Exception(ctx) 20 | log.Println("Panic!", ex) 21 | debug.PrintStack() 22 | 23 | w.WriteHeader(http.StatusInternalServerError) 24 | 25 | fmt.Fprintln(w, "Panic!", ex) 26 | } 27 | -------------------------------------------------------------------------------- /assets/templates/checkout.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "checkout_title"}} 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

{{tr "checkout_thanks"}}

11 |

{{tr "checkout_explain"}}

12 | 13 | 14 | 15 |
{{tr "plan"}}{{tr $.User.Plan.Msg}}
{{tr "expires"}}{{$.User.PlanExpire | date}}
16 |

{{tr "checkout_settings"}}

17 |
18 | 19 | -------------------------------------------------------------------------------- /assets/templates/_track-name.gohtml: -------------------------------------------------------------------------------- 1 | 2 | {{with .Info.Artist}} 3 | {{.}} 4 | {{else}} 5 | {{tr "unknownartist"}} 6 | {{end}} - 7 | {{with .Info.Album}} 8 | {{.}} 9 | {{else}} 10 | {{tr "unknownalbum"}} 11 | {{end}} - 12 | {{with (or .Info.Title .Filename)}} 13 | {{.}} 14 | {{- else -}} 15 | {{tr "unknownartist"}} 16 | {{- end -}} 17 | 18 | -------------------------------------------------------------------------------- /web/admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sort" 7 | 8 | "github.com/guregu/intertube/tube" 9 | ) 10 | 11 | func adminIndex(ctx context.Context, w http.ResponseWriter, r *http.Request) { 12 | users, err := tube.GetAllUsers(ctx) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | sort.Slice(users, func(i, j int) bool { 18 | a, b := users[i], users[j] 19 | return a.LastMod.After(b.LastMod) 20 | }) 21 | 22 | data := struct { 23 | Users []tube.User 24 | }{ 25 | Users: users, 26 | } 27 | 28 | renderTemplate(ctx, w, "admin", data, http.StatusOK) 29 | } 30 | -------------------------------------------------------------------------------- /assets/templates/admin.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}admin 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

usage

11 | 12 | {{range $.Users}} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{end}} 23 |
{{.ID}}{{.Email}}{{.Usage | bytesize}}{{.Plan}}{{.PlanStatus}}{{.Regdate | timestamp}}{{.LastMod | timestamp}}
24 |
25 | 26 | -------------------------------------------------------------------------------- /cmd/tubesync/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/guregu/intertube/tubesync 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/guregu/intertube v0.0.0-20240504223008-df244dad8902 7 | golang.org/x/net v0.24.0 8 | golang.org/x/term v0.19.0 9 | ) 10 | 11 | require ( 12 | github.com/aws/aws-sdk-go v1.52.2 // indirect 13 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 14 | github.com/guregu/dynamo v1.22.2 // indirect 15 | github.com/jmespath/go-jmespath v0.4.0 // indirect 16 | github.com/karlseguin/ccache/v2 v2.0.8 // indirect 17 | golang.org/x/crypto v0.22.0 // indirect 18 | golang.org/x/sync v0.7.0 // indirect 19 | golang.org/x/sys v0.20.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /assets/templates/forgot.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "forgot_title"}} 6 | 7 | 8 |
9 |

{{tr "forgot_title"}}

10 |

{{tr "forgot_intro"}}

11 |

{{$.ErrorMsg}}

12 |
13 | 14 | 15 | 16 |
17 |
18 | 21 |
22 | 23 | -------------------------------------------------------------------------------- /assets/templates/_style-groove.gohtml: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #282828; 3 | --fg: #bdae93; 4 | --hi-bg: #322f3da3; 5 | --hi-fg: #ebdbb2; 6 | --input-bg: #3c3836; 7 | --button-bg: #504945; 8 | --links: #83a598; 9 | --nav-bg: #1c1a1a; 10 | --nav-border: #423e3e; 11 | --border: black; 12 | --frost-bg: #1d1b1bb3; 13 | --frost-fallback: #1d1b1bf7; 14 | --logo: darkgrey; 15 | --audio-bg: rgba(140, 140, 140, 0.8); 16 | } 17 | /* TODO: move this outside if we style lightmode input */ 18 | input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="file"], textarea, select, select:focus { 19 | background-color: var(--input-bg); 20 | color: var(--hi-fg); 21 | } 22 | input[type="submit"], button { 23 | background-color: var(--button-bg); 24 | color: var(--hi-fg); 25 | } -------------------------------------------------------------------------------- /deploy/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Thanks @kyokomi 3 | 4 | APP_NAME=intertube 5 | S3_REGION=us-west-2 6 | S3_BUCKET=deploy.inter.tube 7 | CIRCLE_BUILD_NUM=`date +%s` 8 | 9 | TAR_NAME=${APP_NAME}.zip 10 | 11 | # s3 upload 12 | aws s3 cp --region $S3_REGION $TAR_NAME s3://$S3_BUCKET/$APP_NAME/release/$CIRCLE_BUILD_NUM/$TAR_NAME 13 | 14 | # lambda deploy 15 | aws lambda update-function-code --region $S3_REGION --function-name "tube-web" --s3-bucket $S3_BUCKET --s3-key $APP_NAME/release/$CIRCLE_BUILD_NUM/$TAR_NAME --no-cli-pager 16 | aws lambda update-function-code --region $S3_REGION --function-name "tube-trigger" --s3-bucket $S3_BUCKET --s3-key $APP_NAME/release/$CIRCLE_BUILD_NUM/$TAR_NAME --no-cli-pager 17 | aws lambda update-function-code --region $S3_REGION --function-name "tube-process" --s3-bucket $S3_BUCKET --s3-key $APP_NAME/release/$CIRCLE_BUILD_NUM/$TAR_NAME --no-cli-pager 18 | -------------------------------------------------------------------------------- /assets/templates/_nav-in.gohtml: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /assets/templates/_style-spooky.gohtml: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #35234b; 3 | --fg: #ff8484; 4 | --hi-bg: #5c3b6f5c; 5 | --hi-fg: #ff8484; 6 | --input-bg: #282625; 7 | --input-fg: #ff7a29; 8 | --button-bg: #3c342f; /*#282625*/ 9 | --button-fg: #ff7a29; 10 | --links: #d84c73; 11 | --nav-bg: #1c1a1a; 12 | --nav-border: #493a53; 13 | --border: #360d41; /*#cc00ff1c; /*#360d41*/ 14 | --frost-bg: #3c173394; 15 | --frost-fallback: #2f103cf0; 16 | --frost-blur: 9px; 17 | --player-title: #ff7a29; 18 | --logo: #838284; 19 | --audio-bg: rgb(81 82 110 / 80%); 20 | } 21 | /* TODO: move this outside if we style lightmode input */ 22 | input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="file"], textarea, select, select:focus { 23 | background-color: var(--input-bg); 24 | color: var(--input-fg, var(--hi-fg)); 25 | } 26 | input[type="submit"], button { 27 | background-color: var(--button-bg); 28 | color: var(--button-fg, var(--hi-fg)); 29 | } -------------------------------------------------------------------------------- /event/sqs.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/aws/aws-lambda-go/events" 9 | "github.com/guregu/intertube/storage" 10 | "github.com/guregu/intertube/tube" 11 | "github.com/guregu/intertube/web" 12 | ) 13 | 14 | func handleFileQueue(ctx context.Context, e events.SQSEvent) (string, error) { 15 | for _, rec := range e.Records { 16 | var fe storage.FileEvent 17 | if err := json.Unmarshal([]byte(rec.Body), &fe); err != nil { 18 | return "", err 19 | } 20 | 21 | u, err := tube.GetUser(ctx, fe.UserID) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | f, err := tube.GetFile(ctx, fe.FileID) 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | if _, err := web.ProcessUpload(ctx, &f, u, fe.Path); err != nil { 32 | return "", err 33 | } 34 | 35 | if err := storage.QueueAck(rec.ReceiptHandle); err != nil { 36 | return "", err 37 | } 38 | } 39 | return fmt.Sprintf("processed %d event(s)", len(e.Records)), nil 40 | } 41 | -------------------------------------------------------------------------------- /storage/retry.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aws/aws-sdk-go/aws/client" 7 | "github.com/aws/aws-sdk-go/aws/request" 8 | ) 9 | 10 | // See: https://future-architect.github.io/articles/20211026a/ 11 | 12 | type Retryer struct { 13 | client.DefaultRetryer 14 | } 15 | 16 | func (r Retryer) ShouldRetry(req *request.Request) bool { 17 | if origErr := req.Error; origErr != nil { 18 | switch origErr.(type) { 19 | case interface{ Temporary() bool }: 20 | if isErrConnectionReset(origErr) { 21 | return true 22 | } 23 | } 24 | } 25 | return r.DefaultRetryer.ShouldRetry(req) 26 | } 27 | 28 | func isErrConnectionReset(err error) bool { 29 | if strings.Contains(err.Error(), "read: connection reset") { 30 | return false 31 | } 32 | 33 | if strings.Contains(err.Error(), "use of closed network connection") || 34 | strings.Contains(err.Error(), "connection reset") || 35 | strings.Contains(err.Error(), "broken pipe") { 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | -------------------------------------------------------------------------------- /tube/star.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Star struct { 9 | UserID int `dynamo:",hash"` 10 | SSID SSID `dynamo:",range"` 11 | Date time.Time 12 | } 13 | 14 | func SetStar(ctx context.Context, userID int, ssid SSID, date time.Time) error { 15 | table := dynamoTable("Stars") 16 | return table.Put(Star{ 17 | UserID: userID, 18 | SSID: ssid, 19 | Date: date, 20 | }).Run() 21 | } 22 | 23 | func DeleteStar(ctx context.Context, userID int, ssid string) error { 24 | table := dynamoTable("Stars") 25 | return table.Delete("UserID", userID).Range("SSID", ssid).Run() 26 | } 27 | 28 | func GetStars(ctx context.Context, userID int) (map[SSID]Star, error) { 29 | table := dynamoTable("Stars") 30 | iter := table.Get("UserID", userID).Iter() 31 | stars := make(map[SSID]Star) 32 | var s Star 33 | for iter.Next(&s) { 34 | stars[s.SSID] = s 35 | } 36 | if err := iter.Err(); err != nil && iter.Err() != ErrNotFound { 37 | return nil, err 38 | } 39 | return stars, nil 40 | } 41 | -------------------------------------------------------------------------------- /web/migrate.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/guregu/intertube/tube" 9 | ) 10 | 11 | func MIGRATE_MAKEDUMPS() { 12 | ctx := context.Background() 13 | users, err := tube.GetAllUsers(ctx) 14 | if err != nil { 15 | panic(err) 16 | } 17 | for _, u := range users { 18 | if u.Tracks == 0 { 19 | continue 20 | } 21 | if err := tube.RecreateDump(ctx, u.ID, time.Now().UTC()); err != nil { 22 | panic(err) 23 | } 24 | fmt.Println("dumped", u.ID, u.Email) 25 | } 26 | } 27 | 28 | func MIGRATE_SORTID() { 29 | ctx := context.Background() 30 | 31 | fmt.Println("fixing sort IDs...") 32 | 33 | iter := tube.GetALLTracks(ctx) 34 | var t tube.Track 35 | for iter.Next(&t) { 36 | if t.SortID == t.SortKey() { 37 | fmt.Println("skipping", t.SortKey()) 38 | continue 39 | } 40 | if err := t.RefreshSortID(ctx); err != nil { 41 | fmt.Println("ERROR", err, t.ID, t.UserID, t.SortKey()) 42 | continue 43 | } 44 | fmt.Println("set", t.SortID, "/", t.UserID) 45 | } 46 | if iter.Err() != nil { 47 | panic(iter.Err()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tube/event.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/rand" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | type Event struct { 12 | UserID int 13 | Time Timegarb 14 | Kind EventKind 15 | } 16 | 17 | type EventKind string 18 | 19 | const ( 20 | EventPaid EventKind = "paid" 21 | ) 22 | 23 | type Timegarb struct { 24 | time.Time 25 | Garb string 26 | } 27 | 28 | func NewTimegarb(t time.Time) Timegarb { 29 | garb := strconv.FormatUint(rand.Uint64(), 36) 30 | return Timegarb{ 31 | Time: t.UTC(), 32 | Garb: garb, 33 | } 34 | } 35 | 36 | func (t Timegarb) MarshalText() ([]byte, error) { 37 | text, err := t.Time.MarshalText() 38 | if err != nil { 39 | return nil, err 40 | } 41 | text = append(text, []byte(" "+t.Garb)...) 42 | return text, nil 43 | } 44 | 45 | func (t *Timegarb) UnmarshalText(text []byte) error { 46 | idx := bytes.IndexRune(text, ' ') 47 | if idx < 0 || idx == len(text) { 48 | return fmt.Errorf("invalid timegarb: %s", string(text)) 49 | } 50 | if err := t.Time.UnmarshalText(text[:idx]); err != nil { 51 | return err 52 | } 53 | t.Garb = string(text[idx+1:]) 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | dynamodb: 5 | image: amazon/dynamodb-local:latest 6 | ports: 7 | - "8880:8000" 8 | command: "-jar DynamoDBLocal.jar -sharedDb" 9 | minio: 10 | image: minio/minio:latest 11 | environment: &minio-vars 12 | MINIO_ROOT_USER: root 13 | MINIO_ROOT_PASSWORD: password 14 | command: server --console-address ":9001" /data 15 | ports: 16 | - "9000:9000" 17 | - "9001:9001" 18 | createbuckets: 19 | image: minio/mc:latest 20 | depends_on: 21 | - minio 22 | environment: 23 | <<: *minio-vars 24 | INTERTUBE_UPLOADS_BUCKET: intertube-uploads 25 | INTERTUBE_FILES_BUCKET: intertube 26 | entrypoint: > 27 | /bin/sh -c " 28 | /usr/bin/mc alias set storage http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; 29 | /usr/bin/mc mb storage/$${INTERTUBE_UPLOADS_BUCKET}; 30 | /usr/bin/mc policy set public storage/$${INTERTUBE_UPLOADS_BUCKET}; 31 | /usr/bin/mc mb storage/$${INTERTUBE_FILES_BUCKET}; 32 | /usr/bin/mc policy set public storage/$${INTERTUBE_FILES_BUCKET}; 33 | exit 0; 34 | " -------------------------------------------------------------------------------- /web/metadata_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var testcases = []struct { 9 | name string 10 | expect guessedMeta 11 | }{ 12 | {"blah.mp3", guessedMeta{title: "blah"}}, 13 | {"Artist - Album - 01 Track.mp3", guessedMeta{artist: "Artist", album: "Album", track: 1, title: "Track"}}, 14 | {"2-03 songtitle.mp3", guessedMeta{disc: 2, track: 3, title: "songtitle"}}, 15 | {"1-abc.mp3", guessedMeta{track: 1, title: "abc"}}, 16 | {"01-abc.mp3", guessedMeta{track: 1, title: "abc"}}, 17 | {"Bulldada - What a Bunch of Bulldada - 09 22nd Century Yahoo Answers Man.mp3", guessedMeta{artist: "Bulldada", album: "What a Bunch of Bulldada", track: 9, title: "22nd Century Yahoo Answers Man"}}, 18 | {"YTMND Soundtrack - Volume 6 - 14 - DVDA - America Fuck Yeah.mp3", guessedMeta{albumArtist: "YTMND Soundtrack", artist: "DVDA", album: "Volume 6", track: 14, title: "America Fuck Yeah"}}, 19 | } 20 | 21 | func TestMetadataGuess(t *testing.T) { 22 | for _, testcase := range testcases { 23 | meta := guessMetadata(testcase.name, "MP3") 24 | expect := testcase.expect 25 | expect.ftype = "MP3" 26 | if !reflect.DeepEqual(meta, expect) { 27 | t.Error(meta, "!=", expect) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/templates/recover.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "recover_title"}} 6 | 7 | 8 |
9 |

{{tr "recover_title"}}

10 |

{{tr "recover_intro"}}

11 |

{{$.ErrorMsg}}

12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 | -------------------------------------------------------------------------------- /web/static.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/guregu/intertube/tube" 8 | ) 9 | 10 | // mostly-static pages 11 | 12 | func homepage(ctx context.Context, w http.ResponseWriter, r *http.Request) { 13 | u, _ := userFrom(ctx) 14 | data := struct { 15 | User tube.User 16 | }{ 17 | User: u, 18 | } 19 | renderTemplate(ctx, w, "index", data, http.StatusOK) 20 | } 21 | 22 | func moreStuff(ctx context.Context, w http.ResponseWriter, r *http.Request) { 23 | u, _ := userFrom(ctx) 24 | data := struct { 25 | User tube.User 26 | }{ 27 | User: u, 28 | } 29 | renderTemplate(ctx, w, "more", data, http.StatusOK) 30 | } 31 | 32 | func subsonicHelp(ctx context.Context, w http.ResponseWriter, r *http.Request) { 33 | u, _ := userFrom(ctx) 34 | data := struct { 35 | User tube.User 36 | }{ 37 | User: u, 38 | } 39 | renderTemplate(ctx, w, "subsonic", data, http.StatusOK) 40 | } 41 | 42 | func privacyPolicy(ctx context.Context, w http.ResponseWriter, r *http.Request) { 43 | renderTemplate(ctx, w, "privacy", nil, http.StatusOK) 44 | } 45 | 46 | func termsOfService(ctx context.Context, w http.ResponseWriter, r *http.Request) { 47 | renderTemplate(ctx, w, "terms", nil, http.StatusOK) 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 guregu 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /web/sync.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sort" 7 | 8 | "github.com/guregu/intertube/tube" 9 | ) 10 | 11 | func syncForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 12 | u, _ := userFrom(ctx) 13 | 14 | // test lol 15 | tracks, err := u.GetTracks(ctx) 16 | if err != nil { 17 | panic(err) 18 | } 19 | lib := NewLibrary(tracks, nil) 20 | type meta struct { 21 | ID string 22 | Size int 23 | LastMod int64 24 | Path string 25 | URL string 26 | } 27 | metadata := make([]meta, 0, len(lib.tracks)) 28 | index := make(map[string]meta, len(lib.tracks)) 29 | for _, t := range lib.Tracks(organize{}) { 30 | m := meta{ 31 | ID: t.ID, 32 | Size: t.Size, 33 | LastMod: t.LocalMod, 34 | Path: t.VirtualPath(), 35 | URL: presignTrackDL(u, t), 36 | } 37 | metadata = append(metadata, m) 38 | index[m.ID] = m 39 | } 40 | sort.Slice(metadata, func(i, j int) bool { 41 | return metadata[i].Path < metadata[j].Path 42 | }) 43 | 44 | data := struct { 45 | User tube.User 46 | Metadata []meta 47 | Index map[string]meta 48 | }{ 49 | User: u, 50 | Metadata: metadata, 51 | Index: index, 52 | } 53 | renderTemplate(ctx, w, "sync", data, http.StatusOK) 54 | } 55 | -------------------------------------------------------------------------------- /email/mail.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/ses" 9 | ) 10 | 11 | const noreply = " " 12 | 13 | var mailer *ses.SES 14 | 15 | // TODO: make this configurable later 16 | // for now just fail without exploding 17 | 18 | func init() { 19 | sesh, err := session.NewSession() 20 | if err != nil { 21 | log.Println("email is not configured:", err) 22 | return 23 | } 24 | mailer = ses.New(sesh, &aws.Config{ 25 | Region: aws.String("us-west-2"), 26 | }) 27 | } 28 | 29 | func Send(from, to, subject, content string) error { 30 | input := &ses.SendEmailInput{ 31 | Source: aws.String(from + noreply), 32 | Destination: &ses.Destination{ 33 | ToAddresses: []*string{aws.String(to)}, 34 | }, 35 | Message: &ses.Message{ 36 | Subject: &ses.Content{ 37 | Data: aws.String(subject), 38 | Charset: aws.String("UTF-8"), 39 | }, 40 | Body: &ses.Body{ 41 | Html: &ses.Content{ 42 | Data: aws.String(content), 43 | Charset: aws.String("UTF-8"), 44 | }, 45 | }, 46 | }, 47 | } 48 | _, err := mailer.SendEmail(input) 49 | return err 50 | } 51 | 52 | func IsEnabled() bool { 53 | return mailer != nil 54 | } 55 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pelletier/go-toml/v2" 8 | ) 9 | 10 | type Config struct { 11 | Domain string `toml:"domain"` 12 | DB struct { 13 | Region string `toml:"region"` 14 | Prefix string `toml:"prefix"` 15 | Endpoint string `toml:"endpoint"` 16 | Debug bool `toml:"debug"` 17 | } `toml:"db"` 18 | Storage struct { 19 | Type string `toml:"type"` 20 | FilesBucket string `toml:"files_bucket"` 21 | UploadsBucket string `toml:"uploads_bucket"` 22 | CacheBucket string `toml:"cache_bucket"` 23 | AccessKeyID string `toml:"access_key_id"` 24 | AccessKeySecret string `toml:"access_key_secret"` 25 | CloudflareAccount string `toml:"cloudflare_account"` 26 | Domain string `toml:"domain"` 27 | Region string `toml:"region"` 28 | Endpoint string `toml:"endpoint"` 29 | } `toml:"storage"` 30 | Queue struct { 31 | SQS string `toml:"sqs"` 32 | Region string `toml:"region"` 33 | } `toml:"queue"` 34 | } 35 | 36 | func readConfig(path string) (Config, error) { 37 | var cfg Config 38 | raw, err := os.ReadFile(path) 39 | if err != nil { 40 | return cfg, fmt.Errorf("failed to read config: %w", err) 41 | } 42 | err = toml.Unmarshal(raw, &cfg) 43 | return cfg, err 44 | } 45 | -------------------------------------------------------------------------------- /storage/sqs.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | "github.com/aws/aws-sdk-go/service/sqs" 10 | ) 11 | 12 | var fileQueue *sqs.SQS 13 | var fileQueueURL string 14 | 15 | func UseSQS(region, href string) { 16 | client := sqs.New(session.Must(session.NewSession(&aws.Config{ 17 | Region: aws.String(region), 18 | }))) 19 | fileQueue = client 20 | fileQueueURL = href 21 | } 22 | 23 | func UsingQueue() bool { 24 | return fileQueue != nil 25 | } 26 | 27 | type FileEvent struct { 28 | UserID int 29 | FileID string 30 | Path string 31 | } 32 | 33 | func QueueAck(msgid string) error { 34 | if !UsingQueue() { 35 | return fmt.Errorf("not using queue") 36 | } 37 | _, err := fileQueue.DeleteMessage(&sqs.DeleteMessageInput{ 38 | QueueUrl: &fileQueueURL, 39 | ReceiptHandle: &msgid, 40 | }) 41 | return err 42 | } 43 | 44 | func EnqueueFile(event FileEvent) error { 45 | if !UsingQueue() { 46 | return fmt.Errorf("not using queue") 47 | } 48 | 49 | bs, err := json.Marshal(event) 50 | if err != nil { 51 | return err 52 | } 53 | _, err = fileQueue.SendMessage(&sqs.SendMessageInput{ 54 | QueueUrl: &fileQueueURL, 55 | MessageBody: aws.String(string(bs)), 56 | }) 57 | return err 58 | } 59 | -------------------------------------------------------------------------------- /web/buy.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | // "github.com/davecgh/go-spew/spew" 8 | "github.com/stripe/stripe-go/v72" 9 | 10 | "github.com/guregu/intertube/tube" 11 | ) 12 | 13 | func buyForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 14 | if !UseStripe { 15 | http.Error(w, "payment is disabled", http.StatusForbidden) 16 | return 17 | } 18 | 19 | u, loggedIn := userFrom(ctx) 20 | plans := tube.GetPlans() 21 | prices, err := getStripePrices(plans) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | var hasSub bool 27 | if loggedIn { 28 | cust, err := getCustomer(u.CustomerID) 29 | if err != nil { 30 | panic(err) 31 | } 32 | // spew.Dump(cust) 33 | hasSub = cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 34 | } 35 | 36 | data := struct { 37 | StripeKey string 38 | Plans []tube.Plan 39 | Prices map[tube.PlanKind]*stripe.Price 40 | User tube.User 41 | HasSub bool 42 | }{ 43 | StripeKey: stripePublicKey, 44 | Plans: plans, 45 | Prices: prices, 46 | User: u, 47 | HasSub: hasSub, 48 | } 49 | 50 | renderTemplate(ctx, w, "buy", data, http.StatusOK) 51 | } 52 | 53 | // func buySuccess(ctx context.Context, w http.ResponseWriter, r *http.Request) { 54 | // sessionID := r.URL.Query().Get("session_id") 55 | // fmt.Fprintf(w, "success sesh id %s", sessionID) 56 | // } 57 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | # domain = "localhost:9000" 2 | 3 | [db] 4 | # AWS region 5 | # omit to use AWS_REGION env var 6 | # not necessary for DynamoDB local 7 | #region = "us-west-2" 8 | 9 | # Table prefix 10 | # tables are created on startup if they don't exist 11 | prefix = "Tube-" 12 | 13 | # for real DynamoDB, comment this out: 14 | endpoint = "http://localhost:8880" 15 | region = "local" # region can be anything for DynamoDB local 16 | 17 | # ridiculously verbose DB debugging when true 18 | debug = false 19 | 20 | # Blob storage configuration 21 | [storage] 22 | # Bucket names 23 | # "uploads bucket" is what users upload their files to before processing 24 | # you can set a TTL or periodically purge it 25 | # "files bucket" contains tracks organized by user and album art 26 | # you can use the same bucket for both if you want (not recommended) 27 | uploads_bucket = "intertube-uploads" 28 | files_bucket = "intertube" 29 | 30 | ### MinIO configuration 31 | # this matches docker-compose.yml's settings 32 | # useful for local dev 33 | type = "s3" 34 | region = "local" 35 | endpoint = "http://localhost:9000/" 36 | access_key_id = "root" 37 | access_key_secret = "password" 38 | 39 | ### Cloudflare R2 40 | # type = "r2" 41 | # access_key_id = "xxx" 42 | # access_key_secret = "yyy" 43 | # cloudflare_account = "zzz" 44 | # domain = "example.com" # currently unused 45 | 46 | ### Backblaze B2 47 | # type = "b2" 48 | # access_key_id = "aaaaaa" 49 | # access_key_secret = "bbbbb/cccc" 50 | # region = "us-west-002" 51 | -------------------------------------------------------------------------------- /tube/session.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "time" 8 | ) 9 | 10 | const ( 11 | tableSessions = "Sessions" 12 | 13 | sessionTTL = time.Hour * 24 * 7 14 | ) 15 | 16 | type Session struct { 17 | Token string `dynamo:",hash"` 18 | UserID int 19 | Expires time.Time `dynamo:",unixtime"` 20 | IP string 21 | } 22 | 23 | func CreateSession(ctx context.Context, userID int, ipaddr string) (Session, error) { 24 | token, err := randomString(64) 25 | if err != nil { 26 | return Session{}, err 27 | } 28 | 29 | sesh := Session{ 30 | Token: token, 31 | UserID: userID, 32 | Expires: time.Now().UTC().Add(sessionTTL), 33 | IP: ipaddr, 34 | } 35 | sessions := dynamoTable(tableSessions) 36 | err = sessions.Put(sesh).If("attribute_not_exists('Token')").Run() 37 | if err != nil { 38 | return Session{}, err 39 | } 40 | return sesh, nil 41 | } 42 | 43 | func GetSession(ctx context.Context, token string) (Session, error) { 44 | sessions := dynamoTable(tableSessions) 45 | var sesh Session 46 | err := sessions.Get("Token", token).One(&sesh) 47 | if err != nil { 48 | return Session{}, err 49 | } 50 | if time.Now().After(sesh.Expires) { 51 | return Session{}, ErrNotFound 52 | } 53 | return sesh, nil 54 | } 55 | 56 | func randomString(size int) (string, error) { 57 | data := make([]byte, size) 58 | if _, err := rand.Read(data); err != nil { 59 | return "", err 60 | } 61 | return base64.RawURLEncoding.EncodeToString(data), nil 62 | } 63 | -------------------------------------------------------------------------------- /web/render.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "encoding/xml" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | func renderText(w http.ResponseWriter, text string, code int) { 12 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 13 | setCacheHeaders(w) 14 | w.WriteHeader(code) 15 | _, err := io.WriteString(w, text) 16 | if err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | func renderJSON(w http.ResponseWriter, data any, code int) { 22 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 23 | setCacheHeaders(w) 24 | w.WriteHeader(code) 25 | if err := json.NewEncoder(w).Encode(data); err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | func renderTemplate(ctx context.Context, w http.ResponseWriter, tmpl string, data any, code int) { 31 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 32 | setCacheHeaders(w) 33 | w.WriteHeader(code) 34 | if err := getTemplate(ctx, tmpl).Execute(w, data); err != nil { 35 | panic(err) 36 | } 37 | } 38 | 39 | func renderXML(w http.ResponseWriter, data any, code int) { 40 | w.Header().Set("Content-Type", "text/xml") 41 | setCacheHeaders(w) 42 | w.WriteHeader(code) 43 | 44 | if _, err := io.WriteString(w, xml.Header); err != nil { 45 | panic(err) 46 | } 47 | if err := xml.NewEncoder(w).Encode(data); err != nil { 48 | panic(err) 49 | } 50 | } 51 | 52 | func setCacheHeaders(w http.ResponseWriter) { 53 | if w.Header().Get("Cache-Control") == "" { 54 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /assets/templates/more.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "more_title"}} 6 | 14 | 15 | 16 | {{render "_nav" $}} 17 |
18 |

{{tr "more_title"}}

19 | 20 |

{{tr "more_subsonic"}}

21 |

📲 {{tr "more_subsoniclink"}}

22 |

(updated april 2023)

23 |

{{tr "more_subsonicintro"}}

24 | 25 |

{{tr "more_sync"}}

26 |

🗃️ {{tr "more_synclink"}}

27 |

{{tr "more_syncintro"}}

28 | 29 | 34 | 35 |
36 | 37 | {{if payment}} 38 |

{{tr "more_support"}}

39 |

{{tr "more_help"}}

40 |

🆘 {{tr "more_helplink"}}

41 |

{{tr "more_helpintro"}}

42 | 43 |
44 | 45 |

{{tr "more_legal"}}

46 | 51 | {{end}} 52 |
53 | 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/guregu/intertube 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.23.1 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.4.0 9 | github.com/akrylysov/algnhsa v1.1.0 10 | github.com/aws/aws-lambda-go v1.47.0 11 | github.com/aws/aws-sdk-go v1.55.5 12 | github.com/davecgh/go-spew v1.1.1 13 | github.com/dustin/go-humanize v1.0.1 14 | github.com/expr-lang/expr v1.16.9 15 | github.com/fsnotify/fsnotify v1.8.0 16 | github.com/guregu/dynamo v1.23.0 17 | github.com/guregu/kami v2.2.1+incompatible 18 | github.com/guregu/tag v0.0.3 19 | github.com/hajimehoshi/go-mp3 v0.3.4 20 | github.com/jfreymuth/oggvorbis v1.0.5 21 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 22 | github.com/karlseguin/ccache/v2 v2.0.8 23 | github.com/mewkiz/flac v1.0.12 24 | github.com/nicksnyder/go-i18n/v2 v2.4.1 25 | github.com/pelletier/go-toml/v2 v2.2.3 26 | github.com/posener/order v0.0.1 27 | github.com/stripe/stripe-go/v72 v72.122.0 28 | golang.org/x/crypto v0.28.0 29 | golang.org/x/net v0.30.0 30 | golang.org/x/sync v0.8.0 31 | golang.org/x/text v0.19.0 32 | ) 33 | 34 | require ( 35 | github.com/jfreymuth/vorbis v1.0.2 // indirect 36 | google.golang.org/protobuf v1.35.1 // indirect 37 | ) 38 | 39 | require ( 40 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 41 | github.com/dimfeld/httptreemux v5.0.1+incompatible // indirect 42 | github.com/golang/protobuf v1.5.4 // indirect 43 | github.com/icza/bitio v1.1.0 // indirect 44 | github.com/jmespath/go-jmespath v0.4.0 // indirect 45 | github.com/mewkiz/pkg v0.0.0-20240627005552-d95bf79ac1c4 // indirect 46 | github.com/rs/cors v1.11.1 47 | github.com/zenazn/goji v1.0.1 // indirect 48 | golang.org/x/sys v0.26.0 // indirect 49 | google.golang.org/appengine v1.6.8 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /assets/templates/settings-password.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "settings_title"}} 6 | 17 | 18 | 19 | {{render "_nav" $}} 20 |
21 |

{{tr "settings_changepass"}}

22 | 23 | {{if $.Success}} 24 |

✔️ {{tr "settings_passchanged"}}

25 | {{end}} 26 |

{{$.ErrorMsg}}

27 |
28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
{{tr "currentpassword"}} 33 | 34 |
{{tr "newpassword"}} 39 | 40 |
{{tr "newpasswordconfirm"}} 45 | 46 |
54 |
55 |

56 | ← {{tr "nav_settings"}} 57 |

58 |
59 | 60 | -------------------------------------------------------------------------------- /assets/templates/music_albums.gohtml: -------------------------------------------------------------------------------- 1 |
2 | {{range $album := $.Albums}} 3 | {{$first := index $album 0}} 4 |
5 |
6 | {{if $first.Picture.ID}} 7 | {{$first.Picture.Desc}} 8 | {{else}} 9 |
10 | {{end}} 11 |
12 |

{{$first.Info.Artist}} - {{$first.Info.Album}}

13 |
    14 | {{range $album}} 15 |
  1. 21 | {{.Info.Title}} 22 | 23 | 24 | 29 |
  2. 30 | {{end}} 31 |
32 |
33 |
34 |
35 | {{end}} 36 |
-------------------------------------------------------------------------------- /tube/id.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | var MusicFolder = SSID{Kind: SSIDFolder, ID: "1"} 10 | 11 | type SSID struct { 12 | Kind SSIDKind 13 | ID string 14 | } 15 | 16 | type SSIDKind rune 17 | 18 | const ( 19 | SSIDArtist SSIDKind = 'A' 20 | SSIDAlbum SSIDKind = 'a' 21 | SSIDTrack SSIDKind = 't' 22 | // SSIDPlaylist SSIDKind = 'P' 23 | SSIDFolder SSIDKind = 'F' 24 | SSIDInvalid SSIDKind = -1 25 | ) 26 | 27 | func (s SSID) MarshalText() ([]byte, error) { 28 | if s.Kind == SSIDInvalid { 29 | return nil, fmt.Errorf("invalid ssid: %s", s.String()) 30 | } 31 | return []byte(s.String()), nil 32 | } 33 | 34 | func (s *SSID) UnmarshalText(text []byte) error { 35 | *s = ParseSSID(string(text)) 36 | return nil 37 | } 38 | 39 | func (s SSID) String() string { 40 | if s.Kind == SSIDFolder { 41 | return s.ID 42 | } 43 | return s.Kind.String() + "-" + s.ID 44 | } 45 | 46 | func (s SSID) IsZero() bool { 47 | return s == SSID{} 48 | } 49 | 50 | func (k SSIDKind) String() string { 51 | if k == SSIDInvalid { 52 | return "~INVALID~" 53 | } 54 | return string(k) 55 | } 56 | 57 | func NewSSID(kind SSIDKind, id string) SSID { 58 | return SSID{ 59 | Kind: kind, 60 | ID: id, 61 | } 62 | } 63 | 64 | func ParseSSID(id string) SSID { 65 | if len(id) == 0 { 66 | return SSID{} 67 | } 68 | id = strings.Replace(id, "!", "-", 1) 69 | if !strings.ContainsRune(id, '-') { 70 | // special case: folders are integers... 71 | if _, err := strconv.Atoi(id); err == nil { 72 | return SSID{Kind: SSIDFolder, ID: id} 73 | } 74 | } 75 | if len(id) < 3 || id[1] != '-' { 76 | return SSID{Kind: SSIDInvalid, ID: ""} 77 | } 78 | rest := id[2:] 79 | switch SSIDKind(id[0]) { 80 | case SSIDArtist: 81 | return SSID{Kind: SSIDArtist, ID: rest} 82 | case SSIDAlbum: 83 | return SSID{Kind: SSIDAlbum, ID: rest} 84 | case SSIDTrack: 85 | return SSID{Kind: SSIDTrack, ID: rest} 86 | // case SSIDPlaylist: 87 | // return SSID{Kind: SSIDPlaylist, ID: rest} 88 | } 89 | return SSID{Kind: SSIDInvalid, ID: id} 90 | } 91 | -------------------------------------------------------------------------------- /web/apiv0.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/guregu/dynamo" 9 | "github.com/guregu/kami" 10 | 11 | "github.com/guregu/intertube/tube" 12 | ) 13 | 14 | func init() { 15 | kami.Post("/api/v0/login", loginV0) 16 | 17 | kami.Use("/api/v0/tracks/", requireLogin) 18 | kami.Get("/api/v0/tracks/", listTracksV0) 19 | } 20 | 21 | func loginV0(ctx context.Context, w http.ResponseWriter, r *http.Request) { 22 | var req struct { 23 | Email string 24 | Password string 25 | } 26 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 27 | panic(err) 28 | } 29 | 30 | email := req.Email 31 | pass := req.Password 32 | 33 | user, err := tube.GetUserByEmail(ctx, email) 34 | if err == tube.ErrNotFound { 35 | panic("no user with that email") 36 | } 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | if !user.ValidPassword(pass) { 42 | panic("bad password") 43 | } 44 | 45 | sesh, err := tube.CreateSession(ctx, user.ID, ipAddress(r)) 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | http.SetCookie(w, validAuthCookie(sesh)) 51 | 52 | data := struct { 53 | Session string 54 | }{ 55 | Session: sesh.Token, 56 | } 57 | 58 | renderJSON(w, data, http.StatusOK) 59 | } 60 | 61 | func listTracksV0(ctx context.Context, w http.ResponseWriter, r *http.Request) { 62 | u, _ := userFrom(ctx) 63 | 64 | var startFrom dynamo.PagingKey 65 | if start := r.URL.Query().Get("start"); start != "" { 66 | startFrom, _ = dynamo.MarshalItem(struct { 67 | UserID int 68 | ID string 69 | }{ 70 | UserID: u.ID, 71 | ID: start, 72 | }) 73 | } 74 | 75 | data := struct { 76 | Tracks tube.Tracks 77 | Next string 78 | }{} 79 | 80 | tracks, next, err := tube.GetTracksPartial(ctx, u.ID, 500, startFrom) 81 | if err != nil { 82 | panic(err) 83 | } 84 | data.Tracks = tracks 85 | for i, t := range data.Tracks { 86 | t.DL = presignTrackDL(u, t) 87 | data.Tracks[i] = t 88 | } 89 | if next != nil { 90 | data.Next = *next["ID"].S 91 | } 92 | 93 | renderJSON(w, data, http.StatusOK) 94 | } 95 | -------------------------------------------------------------------------------- /assets/templates/login.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "login_title"}} 6 | 14 | 15 | 16 | {{render "_nav" $}} 17 |
18 |

{{tr "login_title"}}

19 | {{if $.ErrorMsg}} 20 |

{{tr $.ErrorMsg}}

21 | {{end}} 22 |
23 | {{with $.Jump}} 24 | 25 | {{end}} 26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 | {{if $.MailEnabled}} 34 |

{{tr "login_forgot"}}

35 |

36 | ・{{tr "login_toforgot"}} 37 |

38 | {{end}} 39 | 40 |

{{tr "login_needreg"}}

41 |

42 | ・{{tr "login_toreg"}} 43 |

44 | 45 | {{if payment}} 46 |

{{tr "intro_what"}}

47 |

{{tr "intro_explain"}}

48 | 49 |

{{tr "intro_why"}}

50 | 61 | 62 |

{{tr "intro_howmuch"}}

63 |

{{tr "intro_pricing"}}

64 | {{end}} 65 |
66 | {{render "_foot" $}} 67 | 68 | -------------------------------------------------------------------------------- /assets/templates/register.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "reg_title"}} 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

{{tr "reg_title"}}

11 |

{{tr "reg_intro"}}

12 | {{if payment}} 13 |

※ {{tr "buy_trial"}}. {{tr "reg_nocc"}}

14 | {{end}} 15 |

{{$.ErrorMsg}}

16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
i agree to the {{tr "nav_tos"}} and {{tr "nav_privacy"}}
25 |
26 |

27 | {{tr "login_cookies"}}
28 | 29 |

30 | {{render "_foot" $}} 31 |
32 | 42 | 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the source code for [inter.tube](https://inter.tube), [as seen on HN's "Stripe killed my music locker service, so I'm open sourcing it"](https://news.ycombinator.com/item?id=36403607) (spoilers: they didn't kill it after all). inter.tube is an online music storage locker service with Subsonic API support. 2 | 3 | Note that none of this code was originally intended to be seen by anyone else, so it's rough, but I hope it is useful to someone. I was inspired to open source it by the recent Apollo debacle. 4 | 5 | ### Architecture 6 | 7 | - Database: DynamoDB 8 | - Storage: S3 or S3-compatible 9 | - Backend: Go, server-side rendering + SubSonic API support 10 | - Frontend: HTML and sprinkles of vanilla JS 11 | - Runs as a regular webserver or serverless via AWS Lambda (serverless docs coming soon) 12 | 13 | ### Running it locally 14 | 15 | Here's a way to run this easily, using DynamoDB local and MinIO. 16 | 17 | Install these things: 18 | - [Go compiler](https://go.dev/dl/) (latest version) 19 | - Docker or equivalent 20 | 21 | ```bash 22 | # git clone this project, then from the root directory: 23 | docker compose up -d 24 | go build 25 | ./intertube --cfg=config.example.toml 26 | ``` 27 | 28 | Then access the site at http://localhost:8000. 29 | 30 | When running in local mode, you can edit the HTML templates and they should reload without having to restart the server. 31 | 32 | ### Running it on The Cloud 33 | 34 | Docs coming soon :-) 35 | 36 | ### Configuration 37 | 38 | See `config.example.toml`. It matches the `docker-compose.yml` settings. 39 | 40 | You can specify the config file with the `--cfg file/path.toml` command line option. 41 | 42 | By default it looks at `config.toml` in the working directory. 43 | 44 | ### Roadmap 45 | 46 | - [x] inter.tube launch 47 | - [x] Local dev mode 48 | - [x] Align latest changes with production 49 | - [ ] Proper self-hosting guide 50 | - [ ] ??? 51 | - [ ] Profit 52 | 53 | ### Contributing 54 | 55 | Contributions, bug reports, and feature suggestions are welcome. 56 | 57 | Please make an issue before you make a PR for non-trivial things. 58 | 59 | You can sponsor this project on GitHub or buy an inter.tube subscription on the official site to help me out as well. 60 | -------------------------------------------------------------------------------- /web/i18n.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "path/filepath" 5 | "strconv" 6 | 7 | "github.com/BurntSushi/toml" 8 | "github.com/kardianos/osext" 9 | "github.com/nicksnyder/go-i18n/v2/i18n" 10 | "golang.org/x/text/language" 11 | ) 12 | 13 | var translations *i18n.Bundle 14 | var defaultLocalizer *i18n.Localizer 15 | 16 | func loadTranslations() { 17 | here, err := osext.ExecutableFolder() 18 | if err != nil { 19 | panic(err) 20 | } 21 | translations = i18n.NewBundle(language.English) 22 | translations.RegisterUnmarshalFunc("toml", toml.Unmarshal) 23 | // TODO: walk text directory 24 | translations.MustLoadMessageFile(filepath.Join(here, "assets", "text", "en.toml")) 25 | // translations.MustLoadMessageFile(filepath.Join(here, "assets", "text", "ja.toml")) 26 | defaultLocalizer = i18n.NewLocalizer(translations, language.Japanese.String()) 27 | } 28 | 29 | func translateFunc(localizer *i18n.Localizer) interface{} { 30 | return func(id string, args ...interface{}) string { 31 | var data map[string]interface{} 32 | if len(args) > 0 { 33 | data = make(map[string]interface{}, len(args)) 34 | for n, iface := range args { 35 | data["v"+strconv.Itoa(n)] = iface 36 | } 37 | } 38 | cfg := &i18n.LocalizeConfig{ 39 | MessageID: id, 40 | TemplateData: data, 41 | } 42 | str, err := localizer.Localize(cfg) 43 | if err != nil { 44 | if str, err = defaultLocalizer.Localize(cfg); err == nil { 45 | return str 46 | } 47 | return "{TL err: " + err.Error() + "}" 48 | } 49 | return str 50 | } 51 | } 52 | 53 | func translateCountFunc(localizer *i18n.Localizer) interface{} { 54 | return func(id string, ct int, args ...interface{}) string { 55 | data := make(map[string]interface{}, len(args)+1) 56 | if len(args) > 0 { 57 | for n, iface := range args { 58 | data["v"+strconv.Itoa(n)] = iface 59 | } 60 | } 61 | data["ct"] = ct 62 | cfg := &i18n.LocalizeConfig{ 63 | MessageID: id, 64 | TemplateData: data, 65 | PluralCount: ct, 66 | } 67 | str, err := localizer.Localize(cfg) 68 | if err != nil { 69 | if str, err = defaultLocalizer.Localize(cfg); err == nil { 70 | return str 71 | } 72 | return "{TL err: " + err.Error() + "}" 73 | } 74 | return str 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /event/dynamodb.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/aws/aws-lambda-go/events" 13 | "github.com/aws/aws-sdk-go/service/dynamodb" 14 | "github.com/guregu/dynamo" 15 | 16 | "github.com/guregu/intertube/tube" 17 | ) 18 | 19 | type trackChange struct { 20 | tracks []tube.Track 21 | deletes []string 22 | lastmod time.Time 23 | } 24 | 25 | // materialize track DB changes 26 | func handleChange(ctx context.Context, e events.DynamoDBEvent) (string, error) { 27 | // lc, _ := lambdacontext.FromContext(ctx) 28 | changes := make(map[int64]*trackChange) 29 | for _, rec := range e.Records { 30 | fmt.Println(rec) 31 | // TODO: maybe ignore Continue time 32 | at := rec.Change.ApproximateCreationDateTime.UTC() 33 | userID, err := rec.Change.Keys["UserID"].Integer() 34 | if err != nil { 35 | log.Println("BAD KEY???", rec.Change.Keys) 36 | log.Println(rec.Change) 37 | continue 38 | } 39 | ch, ok := changes[userID] 40 | if !ok { 41 | ch = &trackChange{} 42 | changes[userID] = ch 43 | } 44 | if at.After(ch.lastmod) { 45 | ch.lastmod = at 46 | } 47 | switch rec.EventName { 48 | case "INSERT", "MODIFY": 49 | var track tube.Track 50 | if err := dynamo.UnmarshalItem(transmute(rec.Change.NewImage), &track); err != nil { 51 | panic(err) 52 | } 53 | ch.tracks = append(ch.tracks, track) 54 | case "REMOVE": 55 | ch.deletes = append(ch.deletes, rec.Change.Keys["ID"].String()) 56 | } 57 | } 58 | 59 | if len(changes) == 0 { 60 | return "nothing to do", nil 61 | } 62 | 63 | var wg sync.WaitGroup 64 | errflag := new(int32) 65 | for uID, ch := range changes { 66 | uID, ch := uID, ch 67 | wg.Add(1) 68 | go func() { 69 | defer wg.Done() 70 | err := tube.RefreshDump(ctx, int(uID), ch.lastmod, ch.tracks, ch.deletes) 71 | if err != nil { 72 | log.Println("ERROR:", err) 73 | atomic.AddInt32(errflag, 1) 74 | } 75 | }() 76 | } 77 | wg.Wait() 78 | if errs := atomic.LoadInt32(errflag); errs > 0 { 79 | return fmt.Sprintf("got %d update error(s)", errs), fmt.Errorf("update failed") 80 | } 81 | 82 | return "OK!", nil 83 | } 84 | 85 | func transmute(garb map[string]events.DynamoDBAttributeValue) map[string]*dynamodb.AttributeValue { 86 | // this is dumb 87 | v, _ := json.Marshal(garb) 88 | var item map[string]*dynamodb.AttributeValue 89 | if err := json.Unmarshal(v, &item); err != nil { 90 | panic(err) 91 | } 92 | return item 93 | } 94 | -------------------------------------------------------------------------------- /assets/templates/privacy.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "privacy_title"}} 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

{{tr "privacy_title"}}

11 |

☛ see also: {{tr "tos_title"}}

12 |

We (inter.tube) strive to keep your personal information safe and secure. We won't sell or abuse your personal information.

13 |

Personal information

14 |

15 |

23 |

24 |

Cookies

25 |

26 |

32 |

33 |

E-mail

34 |

35 |

40 |

41 |

Security

42 |

43 |

49 |

50 |
51 | {{render "_foot" $}} 52 | 53 | -------------------------------------------------------------------------------- /tube/plan.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | type PlanKind string 9 | 10 | const ( 11 | PlanKindNone PlanKind = "" 12 | PlanKindTiny PlanKind = "tiny" 13 | PlanKindSmall PlanKind = "small" 14 | PlanKindBig PlanKind = "big" 15 | PlanKindHuge PlanKind = "huge" 16 | ) 17 | 18 | func (pk PlanKind) Msg() string { 19 | return "plan_" + string(pk) 20 | } 21 | 22 | type Plan struct { 23 | Kind PlanKind 24 | Quota int64 25 | PriceID string 26 | } 27 | 28 | // TODO: make configurable 29 | var plans = map[PlanKind]Plan{ 30 | PlanKindNone: { 31 | Kind: PlanKindNone, 32 | Quota: 50 * 1024 * 1024 * 1024, // 50GB 33 | }, 34 | PlanKindTiny: { 35 | Kind: PlanKindTiny, 36 | Quota: 50 * 1024 * 1024 * 1024, // 50GB 37 | PriceID: "price_1NL7EFKpetgr0YLEouHdIlv1", 38 | }, 39 | PlanKindSmall: { 40 | Kind: PlanKindSmall, 41 | Quota: 250 * 1024 * 1024 * 1024, // 250GB 42 | //PriceID: "price_1I1FzcKpetgr0YLEljmXzSVH", 43 | PriceID: "price_1I9kyKKpetgr0YLEzm17RXIp", 44 | }, 45 | PlanKindBig: { 46 | Kind: PlanKindBig, 47 | Quota: 500 * 1024 * 1024 * 1024, // 500GB 48 | //PriceID: "price_1I1G0TKpetgr0YLEyd1kx5DQ", 49 | PriceID: "price_1I9kyDKpetgr0YLERiDJn7Kf", 50 | }, 51 | PlanKindHuge: { 52 | Kind: PlanKindHuge, 53 | Quota: 2 * 1024 * 1024 * 1024 * 1024, // 2TB 54 | // PriceID: "price_1I1G5gKpetgr0YLEj0xgvqiw", 55 | PriceID: "price_1I9ky7Kpetgr0YLEXAiN8Kfy", 56 | }, 57 | } 58 | 59 | func GetPlan(kind PlanKind) Plan { 60 | plan, ok := plans[kind] 61 | if !ok { 62 | panic(fmt.Errorf("no such plan: %v", kind)) 63 | } 64 | return plan 65 | } 66 | 67 | func GetPlans() []Plan { 68 | var all []Plan 69 | for _, p := range plans { 70 | if p.Kind == PlanKindNone { 71 | continue 72 | } 73 | all = append(all, p) 74 | } 75 | sort.Slice(all, func(i, j int) bool { 76 | return all[i].Quota < all[j].Quota 77 | }) 78 | return all 79 | } 80 | 81 | type PlanStatus string 82 | 83 | const ( 84 | PlanStatusActive PlanStatus = "active" 85 | PlanStatusTrialing PlanStatus = "trialing" 86 | PlanStatusIncomplete PlanStatus = "incomplete" 87 | PlanStatusIncompleteExpired PlanStatus = "incomplete_expired" 88 | PlanStatusPastDue PlanStatus = "past_due" 89 | PlanStatusCanceled PlanStatus = "canceled" 90 | PlanStatusUnpaid PlanStatus = "unpaid" 91 | ) 92 | 93 | func (ps PlanStatus) Active() bool { 94 | switch ps { 95 | case PlanStatusActive, PlanStatusTrialing: 96 | return true 97 | case PlanStatusCanceled: 98 | // TODO: double check 99 | return false 100 | } 101 | return false 102 | } 103 | -------------------------------------------------------------------------------- /tube/db.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 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/guregu/dynamo" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | var dynamoTables = map[string]any{ 17 | "Counters": counter{}, 18 | "Files": File{}, 19 | "Playlists": Playlist{}, 20 | "Sessions": Session{}, 21 | "Stars": Star{}, 22 | "Tracks": Track{}, 23 | "Users": User{}, 24 | } 25 | 26 | var ErrNotFound = dynamo.ErrNotFound 27 | 28 | var ( 29 | dbPrefix string = "Tube-" 30 | db *dynamo.DB 31 | useDump = false 32 | ) 33 | 34 | func Init(region, prefix, endpoint string, debug bool) { 35 | dbPrefix = prefix 36 | var err error 37 | var sesh *session.Session 38 | if endpoint == "" { 39 | sesh, err = session.NewSession() 40 | } else { 41 | log.Println("Using DynamoDB endpoint:", endpoint) 42 | sesh, err = session.NewSession(&aws.Config{ 43 | Endpoint: &endpoint, 44 | Credentials: credentials.NewStaticCredentials("dummy", "dummy", ""), 45 | }) 46 | if region == "" { 47 | region = "local" 48 | } 49 | } 50 | if err != nil { 51 | panic(err) 52 | } 53 | cfg := &aws.Config{ 54 | Region: ®ion, 55 | } 56 | if endpoint == "" && region == "" { 57 | region = os.Getenv("AWS_REGION") 58 | } 59 | if debug { 60 | cfg.LogLevel = aws.LogLevel(aws.LogDebugWithHTTPBody) 61 | } 62 | db = dynamo.New(sesh, cfg) 63 | } 64 | 65 | func dynamoTable(name string) dynamo.Table { 66 | return db.Table(dbPrefix + name) 67 | } 68 | 69 | type createTabler interface { 70 | CreateTable(*dynamo.CreateTable) 71 | } 72 | 73 | func CreateTables(ctx context.Context) error { 74 | log.Println("Checking DynamoDB tables... prefix =", dbPrefix) 75 | 76 | grp, ctx := errgroup.WithContext(ctx) 77 | for name, model := range dynamoTables { 78 | name := dynamoTable(name).Name() 79 | model := model 80 | 81 | if _, err := db.Table(name).Describe().RunWithContext(ctx); err == nil { 82 | continue 83 | } 84 | 85 | log.Println("Creating table:", name) 86 | grp.Go(func() error { 87 | create := db.CreateTable(name, model).OnDemand(true) 88 | if custom, ok := model.(createTabler); ok { 89 | custom.CreateTable(create) 90 | } 91 | return create.RunWithContext(ctx) 92 | }) 93 | } 94 | return grp.Wait() 95 | } 96 | 97 | type counter struct { 98 | ID string `dynamo:",hash"` 99 | Count int 100 | } 101 | 102 | func NextID(ctx context.Context, class string) (n int, err error) { 103 | var ct counter 104 | 105 | table := dynamoTable("Counters") 106 | err = table.Update("ID", class).Add("Count", 1).Value(&ct) 107 | return ct.Count, err 108 | } 109 | 110 | func init() { 111 | dynamo.RetryTimeout = 5 * time.Minute 112 | } 113 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/guregu/intertube/storage" 14 | "github.com/guregu/intertube/tube" 15 | "github.com/guregu/intertube/web" 16 | ) 17 | 18 | var ( 19 | domainFlag = flag.String("domain", "", "domain") 20 | bindFlag = flag.String("addr", ":8000", "addr to bind on") 21 | cfgFlag = flag.String("cfg", "config.toml", "configuration file location") 22 | ) 23 | 24 | func init() { 25 | rand.Seed(time.Now().UnixNano()) 26 | } 27 | 28 | func main() { 29 | flag.Parse() 30 | 31 | if *cfgFlag != "" { 32 | cfg, err := readConfig(*cfgFlag) 33 | if err != nil { 34 | log.Fatalln("Failed to read config file:", *cfgFlag, "error:", err) 35 | } 36 | web.Domain = cfg.Domain 37 | 38 | tube.Init(cfg.DB.Region, cfg.DB.Prefix, cfg.DB.Endpoint, cfg.DB.Debug) 39 | 40 | storageCfg := storage.Config{ 41 | Type: storage.StorageType(cfg.Storage.Type), 42 | FilesBucket: cfg.Storage.FilesBucket, 43 | UploadsBucket: cfg.Storage.UploadsBucket, 44 | CacheBucket: cfg.Storage.CacheBucket, 45 | AccessKeyID: cfg.Storage.AccessKeyID, 46 | AccessKeySecret: cfg.Storage.AccessKeySecret, 47 | Region: cfg.Storage.Region, 48 | Endpoint: cfg.Storage.Endpoint, 49 | CFAccountID: cfg.Storage.CloudflareAccount, 50 | SQSURL: cfg.Queue.SQS, 51 | SQSRegion: cfg.Queue.Region, 52 | } 53 | storage.Init(storageCfg) 54 | } 55 | 56 | if os.Getenv("LAMBDA_TASK_ROOT") != "" { 57 | // TODO: split these into separate binaries maybe 58 | mode := os.Getenv("MODE") 59 | log.Println("Lambda mode", mode) 60 | switch mode { 61 | case "WEB": 62 | // web server 63 | log.Println("deploy time:", web.Deployed) 64 | web.Load() 65 | startLambda() 66 | case "CHANGE", "FILE": 67 | startEventLambda(mode) 68 | } 69 | return 70 | } 71 | 72 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 73 | if err := tube.CreateTables(ctx); err != nil { 74 | log.Fatalln("Failed to create tables:", err) 75 | } 76 | cancel() 77 | 78 | if *domainFlag != "" { 79 | web.Domain = *domainFlag 80 | } 81 | 82 | // web.MIGRATE_MAKEDUMPS() 83 | // os.Exit(0) 84 | 85 | // local server for dev 86 | log.Println("Build date:", web.Deployed) 87 | web.DebugMode = true 88 | web.Load() 89 | 90 | log.Println("Starting up local webserver at:", bindAddr()) 91 | closeWatch := web.WatchFiles() 92 | if err := http.ListenAndServe(*bindFlag, nil); err != nil { 93 | panic(err) 94 | } 95 | closeWatch() 96 | } 97 | 98 | func bindAddr() string { 99 | addr := "http://" 100 | if strings.HasPrefix(*bindFlag, ":") { 101 | addr += "localhost" 102 | } 103 | addr += *bindFlag 104 | return addr 105 | } 106 | -------------------------------------------------------------------------------- /web/playlist.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/guregu/intertube/tube" 10 | ) 11 | 12 | func createPlaylistForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 13 | u, _ := userFrom(ctx) 14 | lib, err := getLibrary(ctx, u) 15 | if err != nil { 16 | panic(err) 17 | } 18 | var tracks []tube.Track 19 | query := r.FormValue("q") 20 | if query != "" { 21 | var err error 22 | tracks, err = lib.Query(query) 23 | if err != nil { 24 | panic(err) 25 | } 26 | } 27 | 28 | data := struct { 29 | Tracks []tube.Track 30 | Query string 31 | Playlist tube.Playlist 32 | }{ 33 | Tracks: tracks, 34 | Query: query, 35 | } 36 | 37 | page := "playlist" 38 | if r.FormValue("frag") == "tracks" { 39 | page = "playlist_tracks" 40 | } 41 | 42 | renderTemplate(ctx, w, page, data, http.StatusOK) 43 | } 44 | 45 | /* 46 | {"meta":{"playlist-name":"a","sort-by":"default","sort-order":"ascending"},"form":{"all":[{"inc":true,"attr":"Artist","op":"$1 == $2","val":"\"a\"","expr":"(Artist == \"a\")"}],"expr":"(Artist == \"a\")"}} 47 | */ 48 | type PlaylistRequest struct { 49 | Meta struct { 50 | Name string 51 | SortBy string 52 | SortOrder string 53 | } 54 | Form struct { 55 | All []PlaylistCond 56 | Expr string 57 | } 58 | } 59 | 60 | type PlaylistCond struct { 61 | Include bool `json:"inc"` 62 | Attr string `json:"attr"` 63 | Op string `json:"op"` 64 | Val string `json:"val"` 65 | } 66 | 67 | type PLUIMeta struct { 68 | Conds []PlaylistCond 69 | Ver int 70 | } 71 | 72 | // TODO: static playlist 73 | func createPlaylist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 74 | const playlistVersion = 1 75 | 76 | u, _ := userFrom(ctx) 77 | lib, err := getLibrary(ctx, u) 78 | if err != nil { 79 | panic(err) 80 | } 81 | 82 | var plr PlaylistRequest 83 | if err := json.NewDecoder(r.Body).Decode(&plr); err != nil { 84 | panic(err) 85 | } 86 | expr := plr.Form.Expr 87 | 88 | pl := tube.Playlist{ 89 | UserID: u.ID, 90 | Name: plr.Meta.Name, 91 | 92 | Dynamic: true, 93 | Query: expr, 94 | } 95 | 96 | // test out query 97 | tracks, err := lib.Query(expr) 98 | if err != nil { 99 | panic(err) 100 | } 101 | pl.With(tracks) 102 | 103 | enc, err := json.Marshal(PLUIMeta{ 104 | Conds: plr.Form.All, 105 | Ver: playlistVersion, 106 | }) 107 | if err != nil { 108 | panic(err) 109 | } 110 | pl.UIMeta = enc 111 | 112 | if err := pl.Create(ctx); err != nil { 113 | panic(err) 114 | } 115 | w.Header().Set("Location", fmt.Sprintf("/playlist/%d", pl.ID)) 116 | w.WriteHeader(http.StatusCreated) 117 | } 118 | 119 | func playlistTracks(lib *Library, pl tube.Playlist) ([]tube.Track, error) { 120 | var tracks []tube.Track 121 | var err error 122 | if pl.Dynamic { 123 | tracks, err = lib.Query(pl.Query) 124 | } else { 125 | tracks = lib.TracksByID(pl.Tracks) 126 | } 127 | // TODO: SORT 128 | return tracks, err 129 | } 130 | -------------------------------------------------------------------------------- /web/context.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/nicksnyder/go-i18n/v2/i18n" 9 | 10 | "github.com/guregu/intertube/tube" 11 | ) 12 | 13 | type localizerkey struct{} 14 | type langkey struct{} 15 | type userkey struct{} 16 | type pathkey struct{} 17 | type bypasskey struct{} 18 | 19 | func discover(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { 20 | acceptlang := r.Header.Get("Accept-Language") 21 | localizer := i18n.NewLocalizer(translations, acceptlang) 22 | ctx = withLocalizer(ctx, localizer) 23 | ctx = withLanguage(ctx, acceptlang) 24 | ctx = withPath(ctx, r.URL.Path) 25 | return ctx 26 | } 27 | 28 | func cacheHeaders(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { 29 | w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 30 | // w.Header().Set("Cache-Control", "no-cache, must-revalidate") 31 | 32 | // if DebugMode { 33 | // w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) 34 | // return ctx 35 | // } 36 | 37 | if u, ok := userFrom(ctx); ok && !u.LastMod.IsZero() { 38 | lm := lastestMod(u.LastMod) 39 | w.Header().Set("Last-Modified", lm.Format(http.TimeFormat)) 40 | } else { 41 | w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) 42 | } 43 | return ctx 44 | } 45 | 46 | func lastestMod(usermod time.Time) time.Time { 47 | if usermod.Before(Deployed) { 48 | return Deployed 49 | } 50 | return usermod 51 | } 52 | 53 | func withLocalizer(ctx context.Context, loc *i18n.Localizer) context.Context { 54 | return context.WithValue(ctx, localizerkey{}, loc) 55 | } 56 | 57 | func localizerFrom(ctx context.Context) *i18n.Localizer { 58 | loc, _ := ctx.Value(localizerkey{}).(*i18n.Localizer) 59 | if loc == nil { 60 | return i18n.NewLocalizer(translations, "en") 61 | } 62 | return loc 63 | } 64 | 65 | func withLanguage(ctx context.Context, langs ...string) context.Context { 66 | for _, lang := range langs { 67 | if lang != "" { 68 | return context.WithValue(ctx, langkey{}, lang) 69 | } 70 | } 71 | return ctx 72 | } 73 | 74 | func languageFrom(ctx context.Context) string { 75 | lang, ok := ctx.Value(langkey{}).(string) 76 | if !ok { 77 | return "ja" 78 | } 79 | return lang 80 | } 81 | 82 | func withUser(ctx context.Context, user tube.User) context.Context { 83 | return context.WithValue(ctx, userkey{}, user) 84 | } 85 | 86 | func userFrom(ctx context.Context) (tube.User, bool) { 87 | u, ok := ctx.Value(userkey{}).(tube.User) 88 | return u, ok 89 | } 90 | 91 | // TODO: maybe change this to the URL obj 92 | func withPath(ctx context.Context, path string) context.Context { 93 | return context.WithValue(ctx, pathkey{}, path) 94 | } 95 | 96 | func pathFrom(ctx context.Context) string { 97 | path, _ := ctx.Value(pathkey{}).(string) 98 | return path 99 | } 100 | 101 | func withBypass(ctx context.Context, ok bool) context.Context { 102 | return context.WithValue(ctx, bypasskey{}, ok) 103 | } 104 | 105 | func bypassFrom(ctx context.Context) bool { 106 | ok, _ := ctx.Value(bypasskey{}).(bool) 107 | return ok 108 | } 109 | -------------------------------------------------------------------------------- /tube/playlist.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/guregu/dynamo" 9 | ) 10 | 11 | type Playlist struct { 12 | UserID int `dynamo:",hash"` 13 | ID int `dynamo:",range"` 14 | 15 | Date time.Time 16 | Name string 17 | Desc string 18 | 19 | Tracks []string 20 | Duration int // seconds 21 | 22 | Dynamic bool 23 | Query string 24 | Sort []string 25 | UIMeta []byte 26 | 27 | LastMod time.Time 28 | } 29 | 30 | // type PlaylistEntry struct { 31 | // Ref string 32 | // TrackID string 33 | // } 34 | 35 | func (p *Playlist) Create(ctx context.Context) error { 36 | if p.ID != 0 { 37 | return fmt.Errorf("already exists: %d", p.ID) 38 | } 39 | 40 | id, err := NextID(ctx, "Playlists") 41 | if err != nil { 42 | return err 43 | } 44 | p.ID = id 45 | p.Date = time.Now().UTC() 46 | p.LastMod = p.Date 47 | 48 | table := dynamoTable("Playlists") 49 | return table.Put(p).If("attribute_not_exists('ID')").Run() 50 | } 51 | 52 | func (p *Playlist) Save(ctx context.Context) error { 53 | p.LastMod = time.Now().UTC() 54 | table := dynamoTable("Playlists") 55 | return table.Put(p).Run() 56 | } 57 | 58 | func (p *Playlist) With(tracks []Track) { 59 | p.Tracks = make([]string, 0, len(tracks)) 60 | p.Duration = 0 61 | for _, t := range tracks { 62 | p.Duration += t.Duration 63 | p.Tracks = append(p.Tracks, t.ID) 64 | } 65 | } 66 | 67 | // func (p Playlist) SSID() SSID { 68 | // return SSID{Kind: SSIDPlaylist, ID: fmt.Sprintf("%d.%d", p.UserID, p.ID)} 69 | // } 70 | 71 | // func parsePlaylistID(id string) (userID, pid int, err error) { 72 | // println(id) 73 | // split := strings.Split(id, ".") 74 | // if len(split) != 2 { 75 | // return 0, 0, fmt.Errorf("invalid playlist id") 76 | // } 77 | // userID, err = strconv.Atoi(split[0]) 78 | // if err != nil { 79 | // return 80 | // } 81 | // pid, err = strconv.Atoi(split[1]) 82 | // return 83 | // } 84 | 85 | func GetPlaylist(ctx context.Context, userID int, id int) (Playlist, error) { 86 | var p Playlist 87 | table := dynamoTable("Playlists") 88 | err := table.Get("UserID", userID).Range("ID", dynamo.Equal, id).One(&p) 89 | return p, err 90 | } 91 | 92 | // func GetPlaylistBySSID(ctx context.Context, userID int, ssid SSID) (Playlist, error) { 93 | // _, pid, err := parsePlaylistID(ssid.ID) 94 | // if err != nil { 95 | // return Playlist{}, err 96 | // } 97 | // return GetPlaylist(ctx, userID, pid) 98 | // } 99 | 100 | func GetPlaylists(ctx context.Context, userID int) ([]Playlist, error) { 101 | var pp []Playlist 102 | table := dynamoTable("Playlists") 103 | err := table.Get("UserID", userID).All(&pp) 104 | if err == ErrNotFound { 105 | err = nil 106 | } 107 | return pp, err 108 | } 109 | 110 | func DeletePlaylist(ctx context.Context, userID int, id int) error { 111 | table := dynamoTable("Playlists") 112 | return table.Delete("UserID", userID).Range("ID", id).Run() 113 | } 114 | 115 | // func DeletePlaylistBySSID(ctx context.Context, userID int, ssid SSID) error { 116 | // _, pid, err := parsePlaylistID(ssid.ID) 117 | // if err != nil { 118 | // return err 119 | // } 120 | // return DeletePlaylist(ctx, userID, pid) 121 | // } 122 | -------------------------------------------------------------------------------- /assets/templates/music_all.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 14 | 18 | 19 | 20 | 21 | 22 | {{range $.Tracks}} 23 | 30 | 41 | 51 | 56 | 59 | 64 | 65 | {{end}} 66 | 67 |
  6 | 7 | 8 | {{tr "artist"}} 9 | 11 | 12 | {{tr "album"}} 13 | 15 | 16 | {{tr "title"}} 17 |
31 | 32 | 33 | ▶️ 34 | ↪️ 35 | 36 | 37 | 38 | ⏸️ 39 | 40 | 42 | 43 | {{if .Info.Artist}} 44 | {{.Info.Artist}} 45 | {{else}} 46 | {{.Info.AlbumArtist}} 47 | {{end}} 48 | 49 | 50 | 52 | 53 | {{.Info.Album}} 54 | 55 | 57 | {{with .Info.Title}}{{.}}{{else}}untitled{{end}} 58 | 60 | {{if .Picture.ID}} 61 | {{.Picture.Desc}} 62 | {{end}} 63 |
-------------------------------------------------------------------------------- /web/api.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "runtime/debug" 7 | "time" 8 | 9 | "github.com/guregu/kami" 10 | ) 11 | 12 | var ( 13 | Domain = "inter.tube" 14 | Deployed time.Time 15 | DebugMode = false 16 | ) 17 | 18 | func init() { 19 | kami.PanicHandler = PanicHandler 20 | http.Handle("/", kami.Handler()) 21 | 22 | kami.Use("/", discover) 23 | kami.Use("/", allowGuest( 24 | "/login", "/register", "/forgot", "/recover", 25 | "/terms", "/privacy", "/buy/", "/subsonic", 26 | "/api/v0/login", 27 | "/external/stripe")) 28 | kami.Use("/", requireLogin) 29 | 30 | kami.Get("/", homepage) 31 | kami.Get("/terms", termsOfService) 32 | kami.Get("/privacy", privacyPolicy) 33 | 34 | kami.Get("/login", loginForm) 35 | kami.Post("/login", login) 36 | kami.Post("/logout", logout) 37 | 38 | kami.Get("/register", registerForm) 39 | kami.Post("/register", register) 40 | 41 | kami.Get("/forgot", forgotForm) 42 | kami.Post("/forgot", forgot) 43 | 44 | kami.Get("/recover", recoverForm) 45 | kami.Post("/recover", doRecover) 46 | 47 | kami.Get("/upload", uploadForm) 48 | kami.Post("/upload/track", uploadStart) 49 | kami.Post("/upload/tracks", uploadStart2) 50 | kami.Post("/upload/track/:id", uploadFinish) 51 | 52 | kami.Get("/sync", syncForm) 53 | 54 | kami.Use("/music", cacheHeaders) 55 | kami.Get("/music", showMusic) 56 | kami.Head("/music", showMusicHead) 57 | kami.Use("/music/", cacheHeaders) 58 | kami.Get("/music/:kind", showMusic) 59 | kami.Head("/music/:kind", showMusicHead) 60 | 61 | kami.Delete("/track/:id", deleteTrack) 62 | kami.Post("/track/:id/played", incPlays) 63 | kami.Post("/track/:id/resume", setResume) 64 | kami.Get("/track/:id/edit", editTrackForm) 65 | kami.Post("/track/:id/edit", editTrack) 66 | 67 | kami.Get("/dl/tracks/:id", downloadTrack) 68 | 69 | kami.Get("/playlist/", createPlaylistForm) 70 | kami.Post("/playlist/", createPlaylist) 71 | kami.Get("/playlist/:id", createPlaylistForm) 72 | kami.Post("/playlist/:id", createPlaylist) 73 | 74 | kami.Post("/cache/reset", resetCache) 75 | 76 | kami.Get("/more", moreStuff) 77 | kami.Get("/subsonic", subsonicHelp) 78 | 79 | kami.Use("/settings", ensureCustomer) 80 | kami.Get("/settings", settingsForm) 81 | kami.Post("/settings", settings) 82 | kami.Get("/settings/password", changePasswordForm) 83 | kami.Post("/settings/password", changePassword) 84 | kami.Use("/settings/payment", ensureCustomer) 85 | kami.Get("/settings/payment", stripePortal) 86 | 87 | kami.Use("/buy/", ensureCustomer) 88 | kami.Get("/buy/", buyForm) 89 | kami.Post("/buy/checkout", stripeCheckout) 90 | kami.Get("/buy/success", stripeCheckoutResult) 91 | 92 | // kami.Use("/payment/", requireLogin) 93 | // kami.Get("/payment/", stripePortal) 94 | 95 | kami.Use("/admin/", requireAdmin) 96 | kami.Get("/admin/", adminIndex) 97 | 98 | kami.Post("/external/stripe", stripeWebhook) 99 | } 100 | 101 | func init() { 102 | var dirty bool 103 | if info, ok := debug.ReadBuildInfo(); ok { 104 | for _, kv := range info.Settings { 105 | switch kv.Key { 106 | case "vcs.time": 107 | var err error 108 | Deployed, err = time.Parse(time.RFC3339, kv.Value) 109 | if err != nil { 110 | panic(err) 111 | } 112 | case "vcs.modified": 113 | dirty = kv.Value == "true" 114 | } 115 | } 116 | } 117 | if !dirty && !Deployed.IsZero() { 118 | return 119 | } 120 | Deployed = time.Now().UTC() 121 | } 122 | 123 | func Load() { 124 | log.Println("Loading templates...") 125 | templates = parseTemplates() 126 | 127 | log.Println("Loading translations...") 128 | loadTranslations() 129 | 130 | log.Println("Checking optional features...") 131 | initStripe() 132 | 133 | log.Println("Loaded up") 134 | } 135 | -------------------------------------------------------------------------------- /assets/templates/_style.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/expr.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/expr-lang/expr" 7 | "github.com/expr-lang/expr/vm" 8 | "github.com/guregu/intertube/tube" 9 | ) 10 | 11 | func compile(code string) (*vm.Program, error) { 12 | options := []expr.Option{ 13 | expr.Env(ExprEnv{}), 14 | 15 | // Operators override for date comprising. 16 | expr.Operator("==", "Equal"), 17 | expr.Operator("<", "Before"), 18 | expr.Operator("<=", "BeforeOrEqual"), 19 | expr.Operator(">", "After"), 20 | expr.Operator(">=", "AfterOrEqual"), 21 | 22 | // Time and duration manipulation. 23 | expr.Operator("+", "Add"), 24 | expr.Operator("-", "Sub"), 25 | 26 | // Operators override for duration comprising. 27 | expr.Operator("==", "EqualDuration"), 28 | expr.Operator("<", "BeforeDuration"), 29 | expr.Operator("<=", "BeforeOrEqualDuration"), 30 | expr.Operator(">", "AfterDuration"), 31 | expr.Operator(">=", "AfterOrEqualDuration"), 32 | } 33 | 34 | return expr.Compile(code, options...) 35 | } 36 | 37 | type ExprEnv struct { 38 | datetime 39 | 40 | ID string 41 | SSID tube.SSID 42 | ArtistSSID tube.SSID 43 | 44 | Number int 45 | Total int 46 | Disc int 47 | Discs int 48 | Year int 49 | 50 | Filename string 51 | Filetype string 52 | Size int 53 | Duration int 54 | 55 | Plays int 56 | LastPlay time.Time 57 | Resume float64 58 | 59 | Title string 60 | Artist string 61 | Album string 62 | AlbumArtist string 63 | Composer string 64 | Genre string 65 | Comment string 66 | } 67 | 68 | func NewExprEnv(t tube.Track) ExprEnv { 69 | return ExprEnv{ 70 | ID: t.ID, 71 | SSID: t.TrackSSID(), 72 | ArtistSSID: t.ArtistSSID(), 73 | Number: t.Number, 74 | Total: t.Total, 75 | Disc: t.Disc, 76 | Discs: t.Discs, 77 | Year: t.Year, 78 | Filename: t.Filename, 79 | Filetype: t.Filetype, 80 | Size: t.Size, 81 | Duration: t.Duration, 82 | Plays: t.Plays, 83 | LastPlay: t.LastPlayed, 84 | Resume: t.Resume, 85 | Title: t.Info.Title, 86 | Artist: t.Info.Artist, 87 | Album: t.Info.Album, 88 | AlbumArtist: t.Info.AlbumArtist, 89 | Composer: t.Info.Composer, 90 | Genre: t.Info.Genre, 91 | Comment: t.Info.Comment, 92 | } 93 | } 94 | 95 | // Taken from https://github.com/antonmedv/expr/blob/master/docs/examples/dates_test.go 96 | // MIT license 97 | // https://github.com/antonmedv/expr/blob/master/LICENSE 98 | 99 | type datetime struct{} 100 | 101 | func (datetime) Date(s string) time.Time { 102 | t, err := time.Parse("2006-01-02", s) 103 | if err != nil { 104 | panic(err) 105 | } 106 | return t 107 | } 108 | 109 | func (datetime) Duration(s string) time.Duration { 110 | d, err := time.ParseDuration(s) 111 | if err != nil { 112 | panic(err) 113 | } 114 | return d 115 | } 116 | 117 | func (datetime) Days(n int) time.Duration { 118 | return time.Hour * 24 * time.Duration(n) 119 | } 120 | 121 | func (datetime) Now() time.Time { return time.Now() } 122 | func (datetime) Equal(a, b time.Time) bool { return a.Equal(b) } 123 | func (datetime) Before(a, b time.Time) bool { return a.Before(b) } 124 | func (datetime) BeforeOrEqual(a, b time.Time) bool { return a.Before(b) || a.Equal(b) } 125 | func (datetime) After(a, b time.Time) bool { return a.After(b) } 126 | func (datetime) AfterOrEqual(a, b time.Time) bool { return a.After(b) || a.Equal(b) } 127 | func (datetime) Add(a time.Time, b time.Duration) time.Time { return a.Add(b) } 128 | func (datetime) Sub(a, b time.Time) time.Duration { return a.Sub(b) } 129 | func (datetime) EqualDuration(a, b time.Duration) bool { return a == b } 130 | func (datetime) BeforeDuration(a, b time.Duration) bool { return a < b } 131 | func (datetime) BeforeOrEqualDuration(a, b time.Duration) bool { return a <= b } 132 | func (datetime) AfterDuration(a, b time.Duration) bool { return a > b } 133 | func (datetime) AfterOrEqualDuration(a, b time.Duration) bool { return a >= b } 134 | -------------------------------------------------------------------------------- /web/settings.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/guregu/intertube/storage" 9 | "github.com/guregu/intertube/tube" 10 | ) 11 | 12 | type settingsFormData struct { 13 | User tube.User 14 | Plan tube.Plan 15 | HasSub bool 16 | ErrorMsg string 17 | CacheEnabled bool 18 | } 19 | 20 | func settingsForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 21 | u, _ := userFrom(ctx) 22 | plan := tube.GetPlan(u.Plan) 23 | 24 | var hasSub bool 25 | if UseStripe { 26 | cust, err := getCustomer(u.CustomerID) 27 | if err != nil { 28 | panic(err) 29 | } 30 | // spew.Dump(cust) 31 | hasSub = cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 32 | } 33 | 34 | data := settingsFormData{ 35 | User: u, 36 | HasSub: hasSub, 37 | Plan: plan, 38 | CacheEnabled: storage.IsCacheEnabled(), 39 | } 40 | renderTemplate(ctx, w, "settings", data, http.StatusOK) 41 | } 42 | 43 | func settings(ctx context.Context, w http.ResponseWriter, r *http.Request) { 44 | u, _ := userFrom(ctx) 45 | plan := tube.GetPlan(u.Plan) 46 | 47 | cust, err := getCustomer(u.CustomerID) 48 | if err != nil { 49 | fmt.Println("cust err", err) 50 | // panic(err) 51 | } 52 | // spew.Dump(cust) 53 | hasSub := cust != nil && cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 54 | 55 | renderError := func(err error) { 56 | data := settingsFormData{ 57 | User: u, 58 | Plan: plan, 59 | HasSub: hasSub, 60 | ErrorMsg: err.Error(), 61 | CacheEnabled: storage.IsCacheEnabled(), 62 | } 63 | renderTemplate(ctx, w, "settings", data, http.StatusOK) 64 | } 65 | 66 | email := r.FormValue("email") 67 | if email != "" && u.Email != email { 68 | if err := u.SetEmail(ctx, email); err != nil { 69 | renderError(err) 70 | return 71 | } 72 | } 73 | 74 | theme := r.FormValue("theme") 75 | if u.Theme != theme { 76 | if err := u.SetTheme(ctx, theme); err != nil { 77 | renderError(err) 78 | return 79 | } 80 | } 81 | 82 | disp := tube.DisplayOptions{} 83 | disp.Stretch = r.FormValue("display-stretch") == "on" 84 | switch r.FormValue("musiclink") { 85 | case "albums": 86 | disp.MusicLink = tube.MusicLinkAlbums 87 | default: 88 | disp.MusicLink = tube.MusicLinkDefault 89 | } 90 | switch r.FormValue("trackselect") { 91 | case "ctrl": 92 | disp.TrackSelect = tube.TrackSelCtrlKey 93 | default: 94 | disp.TrackSelect = tube.TrackSelDefault 95 | } 96 | if u.Display != disp { 97 | if err := u.SetDisplayOpt(ctx, disp); err != nil { 98 | renderError(err) 99 | return 100 | } 101 | } 102 | 103 | http.Redirect(w, r, "/settings", http.StatusSeeOther) 104 | } 105 | 106 | func changePasswordForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 107 | u, _ := userFrom(ctx) 108 | data := struct { 109 | User tube.User 110 | ErrorMsg string 111 | Success bool 112 | }{ 113 | User: u, 114 | } 115 | renderTemplate(ctx, w, "settings-password", data, http.StatusOK) 116 | } 117 | 118 | func changePassword(ctx context.Context, w http.ResponseWriter, r *http.Request) { 119 | u, _ := userFrom(ctx) 120 | 121 | renderError := func(err error) { 122 | data := struct { 123 | User tube.User 124 | ErrorMsg string 125 | Success bool 126 | }{ 127 | User: u, 128 | ErrorMsg: err.Error(), 129 | } 130 | renderTemplate(ctx, w, "settings-password", data, http.StatusOK) 131 | } 132 | 133 | oldpw := r.FormValue("old-password") 134 | newpw := r.FormValue("new-password") 135 | confirm := r.FormValue("new-password-confirm") 136 | 137 | if !u.ValidPassword(oldpw) { 138 | renderError(fmt.Errorf("current password is incorrect")) 139 | return 140 | } 141 | 142 | if newpw == "" || confirm == "" { 143 | renderError(fmt.Errorf("missing input")) 144 | return 145 | } 146 | 147 | if newpw != confirm { 148 | renderError(fmt.Errorf("new password and confirmation don't match")) 149 | return 150 | } 151 | 152 | hashed, err := tube.HashPassword(newpw) 153 | if err != nil { 154 | renderError(err) 155 | return 156 | } 157 | 158 | if err := u.SetPassword(ctx, hashed); err != nil { 159 | renderError(err) 160 | return 161 | } 162 | 163 | data := struct { 164 | User tube.User 165 | ErrorMsg string 166 | Success bool 167 | }{ 168 | User: u, 169 | Success: true, 170 | } 171 | renderTemplate(ctx, w, "settings-password", data, http.StatusOK) 172 | } 173 | -------------------------------------------------------------------------------- /assets/templates/terms.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "tos_title"}} 6 | 7 | 8 | {{render "_nav" $}} 9 |
10 |

{{tr "tos_title"}}

11 |

☛ see also: {{tr "privacy_title"}}

12 |

inter.tube is run by one guy. I don't have lawyers or venture capital funding. Our (my) goal is to foster an evironment of mutual respect between us and the users and eliminate as much Bullshit as possible.

13 |

The gist of these terms is: we won't take ownership of your content, you need to be a paying member to retain access, we will do all that we can to Not Be Evil.

14 |

15 |

General

16 | 22 |

Payment

23 | 32 |

Ownership

33 | 40 |

Environment

41 | 45 |

46 |

Support

47 |

48 |

52 |

53 |
54 |

特定商取引法に基づく表示

55 |

We are located in Japan. Servers are primarily in Oregon. The following is a notice required by the Specified Commercial Transaction Act.

56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
販売業者名ローズベリー グレゴリー
販売責任者ローズベリー グレゴリー
サイトinter.tube(インターチューブ)
https://inter.tube
所在地東京都港区 ※詳細については問い合わせください
商品の名称サービス利用料課金
販売価格別途ページにて記載しています
連絡先greg.roseberry@gmail.com
お支払方法クレジットカード
引渡し時期即時
返金アカウント登録日から一ヶ月以内であれば返金に応じます。問い合わせください。
コンテンツの閲覧保証ブラウザChrome / Firefox / Safariの各最新版
103 |
104 |
105 | {{render "_foot" $}} 106 | 107 | -------------------------------------------------------------------------------- /web/subsonic-album.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/guregu/intertube/tube" 11 | ) 12 | 13 | type subsonicAlbum struct { 14 | XMLName xml.Name `xml:"album" json:"-"` 15 | ID tube.SSID `xml:"id,attr" json:"id"` 16 | Name string `xml:"name,attr" json:"name"` 17 | Artist string `xml:"artist,attr" json:"artist"` 18 | ArtistID tube.SSID `xml:"artistId,attr" json:"artistId"` 19 | CoverArt string `xml:"coverArt,omitempty,attr" json:"coverArt,omitempty"` 20 | SongCount int `xml:"songCount,attr" json:"songCount"` 21 | Duration int `xml:"duration,attr" json:"duration"` 22 | Created string `xml:"created,attr" json:"created"` 23 | Year int `xml:"year,attr,omitempty" json:"year,omitempty"` 24 | Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"` 25 | // 26 | // 27 | 28 | Songs []subsonicSong `xml:",omitempty" json:"song,omitempty"` 29 | } 30 | 31 | func newSubsonicAlbum(tt []tube.Track, includeTracks bool) subsonicAlbum { 32 | first := tt[0] 33 | album := subsonicAlbum{ 34 | ID: first.AlbumSSID(), 35 | Name: first.Info.Album, 36 | Artist: first.Info.Artist, // TODO 37 | ArtistID: first.ArtistSSID(), 38 | SongCount: len(tt), 39 | Created: first.Date.Format(subsonicTimeLayout), 40 | Year: first.Year, 41 | } 42 | if first.Picture.ID != "" { 43 | album.CoverArt = first.TrackSSID().String() 44 | } 45 | var dur int 46 | for _, t := range tt { 47 | dur += t.Duration 48 | if includeTracks { 49 | song := newSubsonicSong(t, "song") 50 | if t.Picture.ID == first.Picture.ID { 51 | song.CoverArt = first.TrackSSID().String() 52 | } 53 | album.Songs = append(album.Songs, song) 54 | } 55 | } 56 | album.Duration = dur 57 | return album 58 | } 59 | 60 | func subsonicGetAlbumList2(ctx context.Context, w http.ResponseWriter, r *http.Request) { 61 | u, _ := userFrom(ctx) 62 | filter := subsonicFilter(r) 63 | 64 | type albumlist2 struct { 65 | subsonicResponse 66 | List struct { 67 | Albums []subsonicAlbum `json:"album,omitempty"` 68 | } `xml:"albumList2" json:"albumList2"` 69 | } 70 | 71 | lib, err := getLibrary(ctx, u) 72 | if err != nil { 73 | panic(err) 74 | } 75 | split := lib.Albums(filter) 76 | 77 | albums := make([]subsonicAlbum, 0, len(split)) 78 | for _, a := range split { 79 | if len(a.tracks) == 0 { 80 | continue 81 | } 82 | a := newSubsonicAlbum(a.tracks, false) 83 | albums = append(albums, a) 84 | } 85 | 86 | resp := albumlist2{ 87 | subsonicResponse: subOK(), 88 | } 89 | resp.List.Albums = albums 90 | 91 | writeSubsonic(ctx, w, r, resp) 92 | } 93 | 94 | func subsonicGetAlbumList1(ctx context.Context, w http.ResponseWriter, r *http.Request) { 95 | u, _ := userFrom(ctx) 96 | filter := subsonicFilter(r) 97 | 98 | lib, err := getLibrary(ctx, u) 99 | if err != nil { 100 | panic(err) 101 | } 102 | allAlbums := lib.Albums(filter) 103 | 104 | fmt.Printf("FILTER: %#v\n", filter) 105 | 106 | resp := struct { 107 | subsonicResponse 108 | AlbumList struct { 109 | Albums []subsonicFolder `json:"album,omitempty"` 110 | } `xml:"albumList" json:"albumList"` 111 | }{ 112 | subsonicResponse: subOK(), 113 | } 114 | 115 | for _, a := range allAlbums { 116 | dir := newSubsonicFolder(a) 117 | resp.AlbumList.Albums = append(resp.AlbumList.Albums, dir) 118 | } 119 | 120 | writeSubsonic(ctx, w, r, resp) 121 | } 122 | 123 | func subsonicGetAlbum(ctx context.Context, w http.ResponseWriter, r *http.Request) { 124 | u, _ := userFrom(ctx) 125 | rawid := r.FormValue("id") 126 | ssid := tube.ParseSSID(rawid) 127 | 128 | lib, err := getLibrary(ctx, u) 129 | if err != nil { 130 | panic(err) 131 | } 132 | album, ok := lib.albums[ssid.String()] 133 | if !ok { 134 | writeSubsonic(ctx, w, r, subErr(70, "The requested data was not found.")) 135 | return 136 | } 137 | 138 | type subsonicAlbumResp struct { 139 | subsonicResponse 140 | Album subsonicAlbum `xml:"album" json:"album"` 141 | } 142 | resp := subsonicAlbumResp{ 143 | subsonicResponse: subOK(), 144 | Album: newSubsonicAlbum(album.tracks, true), 145 | } 146 | writeSubsonic(ctx, w, r, resp) 147 | } 148 | 149 | func subsonicFilter(r *http.Request) organize { 150 | sortby := r.FormValue("type") 151 | size, _ := strconv.Atoi(r.FormValue("size")) 152 | if size == 0 { 153 | size = subsonicMaxSize 154 | } 155 | offset, _ := strconv.Atoi(r.FormValue("offset")) 156 | fromYear, _ := strconv.Atoi(r.FormValue("fromYear")) 157 | toYear, _ := strconv.Atoi(r.FormValue("toYear")) 158 | genre := r.FormValue("genre") 159 | mfid := r.FormValue("musicFolderId") 160 | if mfid == "1" { 161 | // "Music" folder 162 | // TODO: hmm 163 | mfid = "" 164 | } 165 | ssid := tube.ParseSSID(mfid) 166 | filter := organize{ 167 | by: sortby, 168 | size: size, 169 | offset: offset, 170 | fromYear: fromYear, 171 | toYear: toYear, 172 | genre: genre, 173 | ssid: ssid, 174 | } 175 | return filter 176 | } 177 | -------------------------------------------------------------------------------- /tube/dump.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "sort" 10 | "time" 11 | 12 | "github.com/karlseguin/ccache/v2" 13 | 14 | "github.com/guregu/dynamo" 15 | "github.com/guregu/intertube/storage" 16 | ) 17 | 18 | const ( 19 | dumpVer = 1 20 | dumpPathFmt = "dump/v%d/%d.db" 21 | ) 22 | 23 | var dumpCache = ccache.New(ccache.Configure()) 24 | 25 | var errStaleDump = fmt.Errorf("stale dump") 26 | 27 | type Dump struct { 28 | UserID int 29 | Time time.Time 30 | Tracks []Track 31 | } 32 | 33 | func (d Dump) Key() string { 34 | return fmt.Sprintf(dumpPathFmt, dumpVer, d.UserID) 35 | } 36 | 37 | func (d Dump) save(ctx context.Context) error { 38 | // TODO: should probably create new file instead of overwriting 39 | var buf bytes.Buffer 40 | if err := json.NewEncoder(&buf).Encode(d); err != nil { 41 | return err 42 | } 43 | r := bytes.NewReader(buf.Bytes()) 44 | return storage.CacheBucket.Put("application/json", d.Key(), r) 45 | } 46 | 47 | func (d Dump) encache() { 48 | log.Println("dump encache", d.Key()) 49 | dumpCache.Set(d.Key(), d, 5*time.Minute) 50 | } 51 | 52 | func (d Dump) stale(usertime time.Time) bool { 53 | return d.Time.Truncate(time.Millisecond).Before(usertime.Truncate(time.Millisecond)) 54 | } 55 | 56 | func (d *Dump) sort() { 57 | sort.Slice(d.Tracks, func(i, j int) bool { 58 | return d.Tracks[i].SortID < d.Tracks[j].SortID 59 | }) 60 | } 61 | 62 | func (d *Dump) Splice(tracks []Track) { 63 | update := make(map[string]Track, len(tracks)) 64 | for _, t := range tracks { 65 | if t.LastMod.After(update[t.ID].LastMod) { 66 | update[t.ID] = t 67 | } 68 | } 69 | // update existing 70 | for i, t := range d.Tracks { 71 | up, ok := update[t.ID] 72 | if !ok { 73 | continue 74 | } 75 | d.Tracks[i] = up 76 | delete(update, t.ID) 77 | log.Println("spliced", d.UserID, up) 78 | } 79 | // new tracks 80 | for _, t := range update { 81 | d.Tracks = append(d.Tracks, t) 82 | d.sort() 83 | log.Println("added", d.UserID, t) 84 | } 85 | } 86 | 87 | func (d *Dump) Remove(trackID string) { 88 | tracks := d.Tracks[:0] 89 | for _, t := range d.Tracks { 90 | if t.ID != trackID { 91 | tracks = append(tracks, t) 92 | log.Println("removed", d.UserID, trackID) 93 | } 94 | } 95 | d.Tracks = tracks 96 | } 97 | 98 | func RefreshDump(ctx context.Context, userID int, at time.Time, updates []Track, deletes []string) error { 99 | u, err := GetUser(ctx, userID) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | d, err := u.GetDump() 105 | if err != nil { 106 | log.Println("RefreshDump GetDump error:", err) 107 | return RecreateDump(ctx, userID, at) 108 | } 109 | 110 | d.Splice(updates) 111 | for _, del := range deletes { 112 | d.Remove(del) 113 | } 114 | d.Time = at 115 | return u.SaveDump(ctx, d) 116 | } 117 | 118 | func RecreateDump(ctx context.Context, userID int, at time.Time) error { 119 | u, err := GetUser(ctx, userID) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | // TODO: need to check if it's an actual unexpected error or just a new dump... 125 | tracks, err := GetTracks(ctx, userID) 126 | // tracks, _, err := GetTracksPartialSorted(ctx, userID, 0, nil) 127 | if err != nil && err != ErrNotFound { 128 | return err 129 | } 130 | d := Dump{ 131 | UserID: u.ID, 132 | Time: at.UTC(), 133 | Tracks: tracks, 134 | } 135 | 136 | return u.SaveDump(ctx, d) 137 | } 138 | 139 | func (u User) SaveDump(ctx context.Context, d Dump) error { 140 | // only save if we 'win' the race 141 | err := u.UpdateLastDump(ctx, d.Time) 142 | if dynamo.IsCondCheckFailed(err) { 143 | log.Println("dump is stale:", err) 144 | // stale 145 | return nil 146 | } 147 | if err != nil { 148 | return err 149 | } 150 | // kewl 151 | log.Println("saving new dump...", d.UserID, d.Time, len(d.Tracks)) 152 | return d.save(ctx) 153 | } 154 | 155 | func (u User) GetDump() (Dump, error) { 156 | key := fmt.Sprintf(dumpPathFmt, dumpVer, u.ID) 157 | 158 | // try cache 159 | if item := dumpCache.Get(key); item != nil { 160 | d := item.Value().(Dump) 161 | if d.stale(u.LastDump) { 162 | log.Println("stale dumppp") 163 | dumpCache.Delete(key) 164 | } else { 165 | log.Println("got dump from cache", u.ID) 166 | return item.Value().(Dump), nil 167 | } 168 | } 169 | 170 | // try s3 171 | d, err := loadDump(key) 172 | if err != nil { 173 | return Dump{}, err 174 | } 175 | if d.stale(u.LastDump) { 176 | return Dump{}, errStaleDump 177 | } 178 | return d, nil 179 | } 180 | 181 | // func GetDump(userID int) (Dump, error) { 182 | // key := fmt.Sprintf(dumpPathFmt, dumpVer, userID) 183 | // if item := dumpCache.Get(key); item != nil { 184 | // log.Println("got dump from cache", userID) 185 | // return item.Value().(Dump), nil 186 | // } 187 | // return loadDump(key) 188 | // } 189 | 190 | func loadDump(key string) (Dump, error) { 191 | r, err := storage.CacheBucket.Get(key) 192 | if err != nil { 193 | return Dump{}, err 194 | } 195 | defer r.Close() 196 | var d Dump 197 | if err := json.NewDecoder(r).Decode(&d); err != nil { 198 | return d, err 199 | } 200 | 201 | // TODO: compare timestamp? 202 | // TODO: TODO 203 | // d.encache() 204 | 205 | return d, nil 206 | } 207 | -------------------------------------------------------------------------------- /tube/file.go: -------------------------------------------------------------------------------- 1 | package tube 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/guregu/dynamo" 10 | "golang.org/x/net/context" 11 | // "github.com/aws/aws-sdk-go/service/cloudfront/sign" 12 | ) 13 | 14 | type File struct { 15 | ID string `dynamo:",hash" index:"UserID-ID-index,range"` 16 | UserID int `index:"UserID-ID-index,hash"` 17 | 18 | Size int64 19 | Type string 20 | Name string 21 | Ext string 22 | Time time.Time 23 | LocalMod int64 24 | Queued time.Time 25 | Started time.Time 26 | Finished time.Time 27 | Ready bool 28 | Deleted bool 29 | 30 | TrackID string 31 | } 32 | 33 | func NewFile(userID int, filename string, size int64) File { 34 | now := time.Now().UTC() 35 | garb, err := randomString(8) 36 | if err != nil { 37 | panic(err) 38 | } 39 | f := File{ 40 | ID: strconv.FormatInt(now.UnixNano(), 36) + "-" + garb, 41 | UserID: userID, 42 | Name: filename, 43 | Ext: path.Ext(filename), 44 | Size: size, 45 | Time: now, 46 | } 47 | return f 48 | } 49 | 50 | func (f File) Create(ctx context.Context) error { 51 | files := dynamoTable("Files") 52 | // users := dynamoTable("Users") 53 | 54 | err := files.Put(f).If("attribute_not_exists('ID')").Run() 55 | return err 56 | 57 | // do this in Track instead 58 | // return users.Update("ID", f.UserID). 59 | // Add("Usage", f.Size). 60 | // If("attribute_exists('ID')"). 61 | // Run() 62 | } 63 | 64 | func (f *File) Finish(ctx context.Context, contentType string, size int64) error { 65 | files := dynamoTable("Files") 66 | err := files.Update("ID", f.ID). 67 | Set("Ready", true). 68 | Set("Finished", time.Now().UTC()). 69 | Set("Size", size). 70 | Set("Type", contentType). 71 | If("attribute_exists('ID')"). 72 | Value(f) 73 | return err 74 | } 75 | 76 | // dont use TODO delete 77 | func (f File) Delete(ctx context.Context) error { 78 | files := dynamoTable("Files") 79 | users := dynamoTable("Users") 80 | // tx := db.WriteTx() 81 | // tx.Delete(files.Delete("ID", f.ID)) 82 | err := files.Update("ID", f.ID). 83 | Set("Deleted", true). 84 | If("attribute_exists('ID')").Run() 85 | if err != nil { 86 | return err 87 | } 88 | return users.Update("ID", f.UserID). 89 | SetExpr("'Usage' = 'Usage' - ?", f.Size). 90 | If("attribute_exists('ID')").Run() 91 | } 92 | 93 | func (f *File) SetTrackID(tID string) error { 94 | files := dynamoTable("Files") 95 | return files.Update("ID", f.ID). 96 | Set("TrackID", tID). 97 | Value(f) 98 | } 99 | 100 | func (f *File) SetQueued(ctx context.Context, at time.Time) error { 101 | files := dynamoTable("Files") 102 | return files.Update("ID", f.ID). 103 | Set("Queued", at). 104 | ValueWithContext(ctx, f) 105 | } 106 | 107 | func (f *File) SetStarted(ctx context.Context, at time.Time) error { 108 | files := dynamoTable("Files") 109 | return files.Update("ID", f.ID). 110 | Set("Started", at). 111 | ValueWithContext(ctx, f) 112 | } 113 | 114 | func (f File) Path() string { 115 | return "up/" + f.ID 116 | } 117 | 118 | func (f File) Status() string { 119 | switch { 120 | case f.Ready, !f.Finished.IsZero(): 121 | return "done" 122 | case !f.Started.IsZero(): 123 | return "processing" 124 | case !f.Queued.IsZero(): 125 | return "queued" 126 | default: 127 | return "uploading" 128 | } 129 | } 130 | 131 | func (f File) Glyph() string { 132 | switch f.Type { 133 | case "audio/mpeg", "audio/ogg", "audio/aac", "audio/opus", "audio/wave", "audio/wav", 134 | "audio/midi", "audio/x-midi": 135 | return "♫" 136 | case "video/mpeg", "video/ogg", "video/quicktime", "video/x-matroska", 137 | "video/x-msvideo", "video/mp2t", "video/3gpp", "video/3gpp2", 138 | "image/tiff": 139 | return "❀" 140 | case "text/plain", "application/msword", "application/rtf", 141 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": 142 | return "✎" 143 | case "application/zip", "application/gzip", "application/x-bzip", "application/x-bzip2", 144 | "application/vnd.rar", "application/x-tar", "application/x-7z-compressed": 145 | return "⬢" 146 | } 147 | return "❐" 148 | } 149 | 150 | func GetFile(ctx context.Context, id string) (File, error) { 151 | table := dynamoTable("Files") 152 | var f File 153 | err := table.Get("ID", id).Consistent(true).One(&f) 154 | return f, err 155 | } 156 | 157 | func GetFiles(ctx context.Context, ids ...string) ([]File, error) { 158 | if len(ids) == 0 { 159 | return nil, nil 160 | } 161 | table := dynamoTable("Files") 162 | var files []File 163 | batch := table.Batch("ID").Get() 164 | for _, id := range ids { 165 | batch.And(dynamo.Keys{id}) 166 | } 167 | iter := batch.Iter() 168 | var f File 169 | for iter.Next(&f) { 170 | if f.Deleted { 171 | fmt.Println("SKIPPING FILE", f) 172 | continue 173 | } 174 | files = append(files, f) 175 | } 176 | err := iter.Err() 177 | if err == dynamo.ErrNotFound { 178 | err = nil 179 | } 180 | return files, err 181 | } 182 | 183 | func GetFilesByUser(ctx context.Context, userID string) ([]File, error) { 184 | table := dynamoTable("Files") 185 | var files []File 186 | err := table.Get("UserID", userID). 187 | Index("UserID-ID-index"). 188 | Filter("Deleted <> ?", true). 189 | Order(dynamo.Descending). 190 | All(&files) 191 | if err == dynamo.ErrNotFound { 192 | err = nil 193 | } 194 | return files, err 195 | } 196 | -------------------------------------------------------------------------------- /web/subsonic-artist.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/xml" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/guregu/intertube/tube" 10 | ) 11 | 12 | type subsonicArtist struct { 13 | XMLName xml.Name `xml:"artist" json:"-"` 14 | // 15 | /* 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | */ 24 | ID tube.SSID `xml:"id,attr" json:"id"` 25 | Name string `xml:"name,attr" json:"name"` 26 | CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` 27 | // TODO: artistImageUrl 28 | AlbumCount int `xml:"albumCount,attr" json:"albumCount"` 29 | Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"` 30 | 31 | Albums []subsonicAlbum `xml:",omitempty" json:"album,omitempty"` 32 | } 33 | 34 | func newSubsonicArtist(a *artistInfo) subsonicArtist { 35 | artist := subsonicArtist{ 36 | ID: a.ssid, 37 | Name: a.name, 38 | AlbumCount: len(a.albums), 39 | } 40 | for _, t := range a.tracks { 41 | if t.Picture.ID != "" { 42 | // TODO: artist pic? 43 | artist.CoverArt = t.TrackSSID().String() 44 | break 45 | } 46 | } 47 | return artist 48 | } 49 | 50 | func subsonicGetArtists(ctx context.Context, w http.ResponseWriter, r *http.Request) { 51 | u, _ := userFrom(ctx) 52 | tracks, err := u.GetTracks(ctx) 53 | if err != nil { 54 | panic(err) 55 | } 56 | 57 | grp := groupTracks(tracks, true) 58 | 59 | type artistsResp struct { 60 | subsonicResponse 61 | Indexes struct { 62 | IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"` 63 | List []subsonicIndex `json:"index,omitempty"` 64 | } `xml:"artists" json:"artists"` 65 | } 66 | 67 | resp := artistsResp{ 68 | subsonicResponse: subOK(), 69 | } 70 | resp.Indexes.IgnoredArticles = subsonicIgnoreArticles 71 | resp.Indexes.List = newSubsonicIndexes(grp) 72 | 73 | writeSubsonic(ctx, w, r, resp) 74 | } 75 | 76 | func subsonicGetIndexes(ctx context.Context, w http.ResponseWriter, r *http.Request) { 77 | u, _ := userFrom(ctx) 78 | tracks, err := u.GetTracks(ctx) 79 | if err != nil { 80 | panic(err) 81 | } 82 | 83 | grp := groupTracks(tracks, true) 84 | 85 | type artistsResp struct { 86 | subsonicResponse 87 | // TODO: ignoredArticles="" 88 | Indexes struct { 89 | IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"` 90 | List []subsonicIndex `json:"index,omitempty"` 91 | } `xml:"indexes" json:"indexes"` 92 | } 93 | 94 | resp := artistsResp{ 95 | subsonicResponse: subOK(), 96 | } 97 | resp.Indexes.IgnoredArticles = subsonicIgnoreArticles 98 | resp.Indexes.List = newSubsonicIndexes(grp) 99 | 100 | writeSubsonic(ctx, w, r, resp) 101 | } 102 | 103 | func subsonicGetArtist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 104 | u, _ := userFrom(ctx) 105 | rawid := r.FormValue("id") 106 | id := tube.ParseSSID(rawid).ID 107 | 108 | tracks, err := u.GetTracks(ctx) 109 | if err != nil { 110 | panic(err) 111 | } 112 | sortTracks(tracks) 113 | 114 | // TODO: make efficient 115 | allAlbums := byAlbum(tracks) 116 | 117 | var albums []subsonicAlbum 118 | for _, a := range allAlbums { 119 | if a[0].AnyArtist() == id /*|| a[0].Info.AlbumArtist == id || a[0].Info.Composer == id */ { 120 | x := newSubsonicAlbum(a, false) 121 | albums = append(albums, x) 122 | } 123 | } 124 | 125 | artist := subsonicArtist{ 126 | ID: albums[0].ArtistID, 127 | Name: albums[0].Artist, 128 | // Name: first., 129 | // TODO: CoverArt: first. 130 | AlbumCount: len(albums), 131 | Albums: albums, 132 | } 133 | 134 | resp := struct { 135 | subsonicResponse 136 | Artist subsonicArtist `xml:"artist" json:"artist"` 137 | }{ 138 | subsonicResponse: subOK(), 139 | Artist: artist, 140 | } 141 | 142 | writeSubsonic(ctx, w, r, resp) 143 | } 144 | 145 | func subsonicGetArtistInfo(ctx context.Context, w http.ResponseWriter, r *http.Request) { 146 | type info struct { 147 | Biography string `xml:"biography,omitempty" json:"biography,omitempty"` 148 | MusicBrainzId string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"` 149 | LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"` 150 | SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"` 151 | MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"` 152 | LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"` 153 | // 154 | } 155 | resp := struct { 156 | subsonicResponse 157 | Info *info `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"` 158 | Info2 *info `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"` 159 | }{ 160 | subsonicResponse: subOK(), 161 | } 162 | 163 | ai := &info{} 164 | if strings.Contains(r.URL.Path, "Info2") { 165 | resp.Info2 = ai 166 | } else { 167 | resp.Info = ai 168 | } 169 | // ai.Biography = "TODO: not implemented yet, sorry~" 170 | 171 | writeSubsonic(ctx, w, r, resp) 172 | } 173 | -------------------------------------------------------------------------------- /assets/templates/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "index_title"}} 6 | 40 | 41 | 42 | {{render "_nav" $}} 43 |
44 |

{{tr "index_title"}}

45 | 46 |

{{tr "loggedinas" $.User.Email}}

47 |

{{tr "index_intro"}}

48 | {{if payment}} 49 | {{if $.User.Trialing}} 50 | {{if $.User.Expired}} 51 | {{if $.User.Grandfathered}} 52 |

※ {{tr "index_grandfathered"}} 🤑

53 | {{else}} 54 |

⚠️ {{tr "index_trialexpired"}}

55 | {{end}} 56 | {{else}} 57 |

※ {{tr "buy_trialnow" ($.User.TimeRemaining | days)}}

58 | {{end}} 59 | {{else}} 60 | {{if $.User.Expired}} 61 |

⚠️ {{tr "index_subexpired"}}

62 | {{end}} 63 | {{end}} 64 | {{end}} 65 | 66 |

{{tr "index_start"}}

67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | {{if payment}} 77 | {{if $.User.TrialOver}} 78 | 79 | 80 | 81 | {{else}} 82 | 83 | 84 | 85 | {{end}} 86 | {{end}} 87 | 88 | 89 | 90 | 91 |
📂{{tr "index_start_upload"}}
🎵{{tr "index_start_library"}}
💸{{tr "index_start_managesub"}}
💸{{tr "index_start_buy"}}
⚙️{{tr "index_start_settings"}}
92 | 93 |

{{tr "index_more"}}

94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 113 | {{if payment}} 114 | 115 | 116 | 117 | 118 | {{end}} 119 |
🤓{{tr "index_more_opensource"}}
📲{{tr "index_more_subsonic"}}
🗃️{{tr "index_more_sync"}}
🆘 {{tr "index_more_help"}}
120 | 121 | {{if payment}} 122 |

{{tr "index_news"}}

123 |
    124 |
  • [2023/09/05] migrated files to a new host (details)
  • 125 |
  • [2023/06/20] we are now open source :-)
  • 126 |
  • [2023/05/03] fixed a bug with the "next" button. re-enabled cache, reset it on settings page if it gets weird (and let me know)
  • 127 |
  • [2023/04/16] added a subsonic help page
  • 128 |
  • [2023/04/16] it's been a while. improved upload page (hopefully less errors) and added the experimental sync feature
  • 129 |
  • [2021/01/27] added native app support via the subsonic API. check it out.
  • 130 |
  • [2021/01/18] we have a soft launch! subscriptions are now available. registration is open with a 14-day free trial.
  • 131 |
132 | 133 |

{{tr "index_comingsoon"}}

134 |
    135 |
  • going to experiment with adding subsonic API support so you can use native apps 136 | 137 |
  • 138 |
  • library UI improvements (play count, tags, folder view?)
  • 139 |
  • desktop app for uploading stuff
  • 140 |
  • what features do you want to see? send me an e-mail.
  • 141 |
142 | {{end}} 143 |
144 | 145 | -------------------------------------------------------------------------------- /assets/templates/track-edit.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "edit_title"}} 6 | 20 | 21 | 22 | {{render "_nav" $}} 23 |
24 |

{{tr "edit_title"}}

25 | {{if $.Multi}} 26 |

🛈 {{tr "edit_multiinfo"}}

27 | {{end}} 28 |

{{$.ErrorMsg}}

29 |
30 | 31 | 32 | 33 | 34 | 35 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 76 | 77 | 78 | 79 | 80 | 81 | {{if (not $.Multi)}} 82 | 83 | 84 | 85 | 86 | {{end}} 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 |
{{tr "title"}} 36 | {{if $.Multi}} 37 |
    38 | {{range $.Tracks}} 39 |
  • {{.Info.Title}}
  • 40 | {{end}} 41 |
42 | {{else}} 43 | 44 | {{end}} 45 |
{{tr "artist"}}
{{tr "album"}}
{{tr "albumartist"}}
{{tr "composer"}}
{{tr "cover"}} 66 | {{if $.Track.Picture.ID}} 67 | {{$.Track.Picture.Desc}} 68 | {{else}} 69 | {{tr "none"}} 70 | {{end}} 71 |
72 | 73 |
74 | 75 |
{{tr "year"}}
{{tr "track"}} of
{{tr "disc"}} of
{{tr "tags"}} {{tr "edit_spacesep"}}
{{tr "comment"}}
105 | 106 | 107 | 108 |
109 |
110 | 171 | 172 | -------------------------------------------------------------------------------- /web/template.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "html/template" 8 | "log" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/dustin/go-humanize" 15 | "github.com/fsnotify/fsnotify" 16 | "github.com/kardianos/osext" 17 | 18 | "github.com/guregu/intertube/storage" 19 | "github.com/guregu/intertube/tube" 20 | ) 21 | 22 | var templates *template.Template 23 | 24 | func getTemplate(ctx context.Context, name string) *template.Template { 25 | t := templates.Lookup(name + ".gohtml") 26 | if t == nil { 27 | panic("no template: " + name) 28 | } 29 | 30 | t, err := t.Clone() 31 | if err != nil { 32 | panic(err) 33 | } 34 | t.Funcs(templateFuncs(ctx)) 35 | return t 36 | } 37 | 38 | func templateFuncs(ctx context.Context) template.FuncMap { 39 | m := make(template.FuncMap) 40 | 41 | // TODO: use jst/account settings and overwrite time stuff 42 | 43 | localizer := localizerFrom(ctx) 44 | lang := languageFrom(ctx) 45 | user, loggedIn := userFrom(ctx) 46 | 47 | m["render"] = renderFunc(ctx) 48 | m["stylesheet"] = renderCSSFunc(ctx, user.Theme) 49 | m["opts"] = func() tube.DisplayOptions { return user.Display } 50 | m["tr"] = translateFunc(localizer) 51 | m["tc"] = translateCountFunc(localizer) 52 | m["lang"] = func() string { return lang } 53 | m["path"] = func() string { return pathFrom(ctx) } 54 | m["loggedin"] = func() bool { return loggedIn } 55 | 56 | return m 57 | } 58 | 59 | func parseTemplates() *template.Template { 60 | here, err := osext.ExecutableFolder() 61 | if err != nil { 62 | panic(err) 63 | } 64 | 65 | globs := []string{ 66 | filepath.Join(here, "assets", "templates", "*.gohtml"), 67 | filepath.Join(here, "assets", "templates", "*.gojs"), 68 | } 69 | 70 | t := template.New("root").Funcs(template.FuncMap{ 71 | "render": renderFunc(context.Background()), 72 | "stylesheet": renderCSSFunc(context.Background(), "default"), 73 | "opts": func() tube.DisplayOptions { return tube.DisplayOptions{} }, 74 | 75 | "timestamp": func(t time.Time) template.HTML { 76 | dateFmt := "2006-01-02 15:04" 77 | rfc := t.Format(time.RFC3339) 78 | return template.HTML( 79 | fmt.Sprintf(``, rfc, t.Format(dateFmt))) 80 | }, 81 | "date": func(t time.Time) template.HTML { 82 | dateFmt := "2006-01-02" 83 | rfc := t.Format(time.RFC3339) 84 | return template.HTML( 85 | fmt.Sprintf(``, rfc, t.Format(dateFmt))) 86 | }, 87 | "shortdate": func(t time.Time) string { 88 | layout := "01-02" 89 | now := time.Now().UTC() 90 | if t.Year() != now.Year() && now.Sub(t) >= 4*30*24*time.Hour { 91 | layout = "2006-01-02" 92 | } 93 | return t.Format(layout) 94 | }, 95 | "days": func(d time.Duration) string { 96 | days := d.Round(24*time.Hour).Hours() / 24 97 | return fmt.Sprintf("%g", days) 98 | }, 99 | "subtract": func(a, b int) int { 100 | return a - b 101 | }, 102 | "inc": func(a int) int { 103 | return a + 1 104 | }, 105 | "add": func(a int, b ...int) int { 106 | for _, n := range b { 107 | a += n 108 | } 109 | return a 110 | }, 111 | "pctof": func(n int64, pct float64) int { 112 | return int(float64(n) * pct) 113 | }, 114 | "concat": func(strs ...string) string { 115 | return strings.Join(strs, "") 116 | }, 117 | "bespace": func(v []string) string { 118 | return strings.Join(v, " ") 119 | }, 120 | "filesize": func(size int64) string { 121 | return humanize.Bytes(uint64(size)) 122 | }, 123 | "bytesize": func(size int64) string { 124 | str := humanize.IBytes(uint64(size)) 125 | return strings.ReplaceAll(str, "iB", "B") 126 | }, 127 | "currency": formatCurrency, 128 | 129 | "tr": translateFunc(defaultLocalizer), 130 | "tc": translateCountFunc(defaultLocalizer), 131 | "lang": func() string { return "en" }, 132 | "path": func() string { return "" }, 133 | "loggedin": func() bool { return false }, 134 | 135 | "sign": func(key string) (string, error) { 136 | return storage.FilesBucket.PresignGet(key, thumbnailDownloadTTL) 137 | }, 138 | 139 | "payment": func() bool { 140 | return UseStripe 141 | }, 142 | 143 | "blankzero": func(i int) string { 144 | if i == 0 { 145 | return "" 146 | } 147 | return strconv.Itoa(i) 148 | }, 149 | }) 150 | 151 | for _, glob := range globs { 152 | template.Must(t.ParseGlob(glob)) 153 | } 154 | 155 | // if DebugMode { 156 | // for _, tt := range t.Templates() { 157 | // fmt.Println("Template:", tt.Name()) 158 | // } 159 | // } 160 | 161 | return t 162 | } 163 | 164 | func renderFunc(ctx context.Context) func(string, interface{}) (template.HTML, error) { 165 | return func(name string, data interface{}) (template.HTML, error) { 166 | target := getTemplate(ctx, name) 167 | if target == nil { 168 | return "", fmt.Errorf("render: missing template: %s", name) 169 | } 170 | var buf bytes.Buffer 171 | err := target.Execute(&buf, data) 172 | if err != nil { 173 | fmt.Println("ERR!!", err) 174 | return "", err 175 | } 176 | return template.HTML(buf.String()), nil 177 | } 178 | } 179 | 180 | func renderCSSFunc(ctx context.Context, active string) func(string, interface{}) (template.CSS, error) { 181 | if active == "" { 182 | active = "default" 183 | } 184 | return func(name string, data interface{}) (template.CSS, error) { 185 | if name == "@" { 186 | name = active 187 | } 188 | name = "_style-" + name 189 | target := getTemplate(ctx, name) 190 | if target == nil { 191 | return "", fmt.Errorf("render: missing template: %s", name) 192 | } 193 | var buf bytes.Buffer 194 | err := target.Execute(&buf, data) 195 | if err != nil { 196 | fmt.Println("ERR!!", err) 197 | return "", err 198 | } 199 | return template.CSS(buf.String()), nil 200 | } 201 | } 202 | 203 | // hot reload for dev 204 | func WatchFiles() func() error { 205 | watcher, err := fsnotify.NewWatcher() 206 | if err != nil { 207 | panic(err) 208 | } 209 | go func() { 210 | for { 211 | select { 212 | case ev := <-watcher.Events: 213 | log.Println("watch event:", ev) 214 | switch filepath.Ext(ev.Name) { 215 | case ".gohtml", ".gojs": 216 | log.Println("Reloading templates...", filepath.Base(ev.Name)) 217 | templates = parseTemplates() 218 | case ".toml": 219 | log.Println("Reloading translations...", filepath.Base(ev.Name)) 220 | loadTranslations() // TODO: this is racy/busted, newer Go versions get mad 221 | } 222 | case err := <-watcher.Errors: 223 | log.Println("watch error:", err) 224 | } 225 | } 226 | }() 227 | 228 | here, err := osext.ExecutableFolder() 229 | if err != nil { 230 | panic(err) 231 | } 232 | if err := watcher.Add(filepath.Join(here, "assets", "templates")); err != nil { 233 | panic(err) 234 | } 235 | if err := watcher.Add(filepath.Join(here, "assets", "text")); err != nil { 236 | panic(err) 237 | } 238 | log.Println("Hot reloading enabled") 239 | return watcher.Close 240 | } 241 | -------------------------------------------------------------------------------- /assets/templates/buy.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "buy_title"}} 6 | 7 | 8 | 75 | 76 | {{render "_nav" $}} 77 |
78 |

{{tr "buy_title"}}

79 |

{{tr "buy_intro"}}

80 |

※ 81 | {{if loggedin}} 82 | {{if $.User.TrialOver}} 83 | {{if $.User.Active}} 84 | {{if (and $.User.Grandfathered $.User.Expired)}} 85 | {{tr "buy_grandfathered"}} 86 | {{else}} 87 | {{tr "buy_subbed" (tr $.User.Plan.Msg)}} 88 | {{if $.User.Canceled}} 89 | ({{tr "canceled"}}) 90 | {{end}} 91 | {{end}} 92 | {{else}} 93 | {{tr "buy_subexpired"}} 94 | {{end}} 95 | {{else}} 96 | {{if $.User.Expired}} 97 | {{tr "buy_trialexpired"}} 98 | {{else}} 99 | {{tr "buy_trialnow" ($.User.TimeRemaining | days)}} 100 | {{end}} 101 | {{end}} 102 | {{else}} 103 | {{tr "buy_trial"}} 104 | {{end}} 105 |

106 |
107 | 108 | 109 | {{range $.Plans}} 110 | 113 | {{end}} 114 | 115 | 116 | {{range $.Plans}} 117 | 120 | {{end}} 121 | 122 | 123 | {{range $.Plans}} 124 | {{$price := index $.Prices .Kind}} 125 | 129 | {{end}} 130 | 131 | 132 | {{range $.Plans}} 133 | {{$price := index $.Prices .Kind}} 134 | 137 | {{end}} 138 | 139 |
111 | {{tr .Kind.Msg}} 112 |
118 | {{.Quota | bytesize}} 119 |
126 | {{currency $price.UnitAmount $price.Currency}}
127 | {{tr "buy_monthly"}} 128 |
135 | 136 |
140 |
141 |

{{tr "buy_explain"}}

142 | {{if (not loggedin)}} 143 |
144 |

{{tr "buy_pitch"}}

145 | 146 |

💁 {{tr "nav_register"}} or {{tr "nav_login"}} to get started. free 14 day trial. no credit card required.

147 |
148 | {{end}} 149 |
150 |

q&a

151 |
152 | what is this? 153 |

inter.tube is a "online music locker". you can upload music to our "cloud" for safekeeping and listen to your library from many devices. we provide storage space and an easy way to listen to music from your browser.

154 |
155 |
156 | is there a free trial? 157 |

yes, when you register an account you automatically get a free 14 day trial. if you like the service, you can subscribe.

158 |
159 |
160 | what formats does it support? 161 |

currently supports: {{tr "supportedformats"}}. if you have the need for other formats, let me know and i'll see what i can do. video isn't supported yet, but might add it later.

162 |
163 |
164 | is this safe? what about my privacy? 165 |

yes. we use Stripe to securely handle payments, so your credit card is never stored on the site. also, we actually care about your privacy. unlike every other service, we don't track your usage and sell your data to advertisers. in fact, we don't even use unnecessary 3rd party cookies or analytics services.

166 |
167 |
168 | can i upgrade or downgrade my plan after i subscribe? 169 |

yes, you can upgrade or downgrade from your settings page. upgrades are prorated. for example, if you upgrade from the $5 plan to the $20 plan, you only need to pay the difference of $15 that month.

170 |
171 |
172 | what happens to my files i cancel my subscription or miss a payment? 173 |

even if you miss a payment or cancel your subscription, we will keep your files safe for as long as possible: at least a few months but likely much longer. you'll get an e-mail warning before anything gets deleted.

174 |
175 |
176 | do you re-encode files or lower bitrates? 177 |

no. we don't touch your files at all. you can upload and enjoy lossless FLAC and high bitrate mp3s with no worries. when you download a file, you'll get exactly the same data as when you uploaded it.

178 |
179 |
180 | does this come with music like Spotify? 181 |

no. this is a "bring your own music" service. however, if you're an artist and would like to share your tunes with the inter.tube community, i would be happy to accommodate.

182 |
183 |
184 |
185 | {{render "_foot" $}} 186 | 187 | 219 | -------------------------------------------------------------------------------- /web/subsonic-playlist.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/xml" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/guregu/intertube/tube" 10 | ) 11 | 12 | type subsonicPlaylist struct { 13 | XMLName xml.Name `xml:"playlist" json:"-"` 14 | ID int `xml:"id,attr" json:"id"` 15 | Name string `xml:"name,attr" json:"name"` 16 | Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` 17 | Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` 18 | Public bool `xml:"public,attr" json:"public"` 19 | SongCount int `xml:"songCount,attr" json:"songCount"` 20 | Duration int `xml:"duration,attr" json:"duration"` 21 | Created string `xml:"created,attr" json:"created"` 22 | Changed string `xml:"changed,attr" json:"changed"` 23 | CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` 24 | 25 | Entries []subsonicSong `json:"entry,omitempty"` 26 | } 27 | 28 | func newSubsonicPlaylist(pl tube.Playlist, tracks []tube.Track, owner tube.User) subsonicPlaylist { 29 | list := subsonicPlaylist{ 30 | ID: pl.ID, 31 | Name: pl.Name, 32 | Comment: pl.Desc, 33 | Owner: owner.Email, 34 | Public: false, 35 | SongCount: len(pl.Tracks), 36 | Duration: pl.Duration, 37 | Created: pl.Date.Format(subsonicTimeLayout), 38 | Changed: pl.LastMod.Format(subsonicTimeLayout), 39 | } 40 | for _, t := range tracks { 41 | if list.CoverArt == "" && t.Picture.ID != "" { 42 | list.CoverArt = t.TrackSSID().String() 43 | } 44 | list.Entries = append(list.Entries, newSubsonicSong(t, "entry")) 45 | } 46 | if len(tracks) == 0 && len(pl.Tracks) > 0 { 47 | list.CoverArt = tube.NewSSID(tube.SSIDTrack, pl.Tracks[0]).String() 48 | } 49 | return list 50 | } 51 | 52 | func subsonicGetPlaylists(ctx context.Context, w http.ResponseWriter, r *http.Request) { 53 | // 54 | u, _ := userFrom(ctx) 55 | 56 | resp := struct { 57 | subsonicResponse 58 | Playlists struct { 59 | List []subsonicPlaylist `json:"playlist,omitempty"` 60 | } `xml:"playlists" json:"playlists"` 61 | }{ 62 | subsonicResponse: subOK(), 63 | } 64 | 65 | pls, err := tube.GetPlaylists(ctx, u.ID) 66 | if err != nil { 67 | panic(err) 68 | } 69 | // lib, err := getLibrary(ctx, u) 70 | // if err != nil { 71 | // panic(err) 72 | // } 73 | 74 | for _, pl := range pls { 75 | // tracks := lib.TracksById(pl.Tracks) 76 | resp.Playlists.List = append(resp.Playlists.List, newSubsonicPlaylist(pl, nil, u)) 77 | } 78 | 79 | writeSubsonic(ctx, w, r, resp) 80 | } 81 | 82 | func subsonicGetPlaylist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 83 | u, _ := userFrom(ctx) 84 | // id := tube.ParseSSID(r.FormValue("id")) 85 | id, err := strconv.Atoi(r.FormValue("id")) 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | lib, err := getLibrary(ctx, u) 91 | if err != nil { 92 | panic(err) 93 | } 94 | pl, err := tube.GetPlaylist(ctx, u.ID, id) 95 | if err == tube.ErrNotFound { 96 | writeSubsonic(ctx, w, r, subErr(70, "The requested data was not found.")) 97 | return 98 | } else if err != nil { 99 | panic(err) 100 | } 101 | tracks, err := playlistTracks(lib, pl) 102 | if err != nil { 103 | panic(err) 104 | } 105 | 106 | resp := struct { 107 | subsonicResponse 108 | Playlist subsonicPlaylist `xml:"playlist" json:"playlist"` 109 | }{ 110 | subsonicResponse: subOK(), 111 | Playlist: newSubsonicPlaylist(pl, tracks, u), 112 | } 113 | writeSubsonic(ctx, w, r, resp) 114 | } 115 | 116 | func subsonicCreatePlaylist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 117 | u, _ := userFrom(ctx) 118 | 119 | r.ParseForm() 120 | name := r.FormValue("name") 121 | // pid := tube.ParseSSID(r.FormValue("playlistId")) 122 | pid, _ := strconv.Atoi(r.FormValue("playlistId")) 123 | 124 | var ids []string 125 | for _, id := range r.Form["songId"] { 126 | ids = append(ids, tube.ParseSSID(id).ID) 127 | } 128 | 129 | lib, err := getLibrary(ctx, u) 130 | if err != nil { 131 | panic(err) 132 | } 133 | var tracks []tube.Track 134 | for _, id := range ids { 135 | t, ok := lib.TrackByID(id) 136 | if !ok { 137 | continue 138 | } 139 | tracks = append(tracks, t) 140 | } 141 | 142 | if pid != 0 { 143 | pl, err := tube.GetPlaylist(ctx, u.ID, pid) 144 | if err != nil { 145 | panic(err) 146 | } 147 | pl.With(tracks) 148 | if err := pl.Save(ctx); err != nil { 149 | panic(err) 150 | } 151 | return 152 | } 153 | 154 | pl := tube.Playlist{ 155 | UserID: u.ID, 156 | Name: name, 157 | } 158 | pl.With(tracks) 159 | if err := pl.Create(ctx); err != nil { 160 | panic(err) 161 | } 162 | 163 | resp := struct { 164 | subsonicResponse 165 | Playlist subsonicPlaylist `xml:"playlist" json:"playlist"` 166 | }{ 167 | subsonicResponse: subOK(), 168 | Playlist: newSubsonicPlaylist(pl, tracks, u), 169 | } 170 | writeSubsonic(ctx, w, r, resp) 171 | } 172 | 173 | func subsonicUpdatePlaylist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 174 | u, _ := userFrom(ctx) 175 | 176 | r.ParseForm() 177 | name := r.FormValue("name") 178 | desc := r.FormValue("comment") 179 | // TODO: public? 180 | 181 | // pid := tube.ParseSSID(r.FormValue("playlistId")) 182 | pid, err := strconv.Atoi(r.FormValue("playlistId")) 183 | if err != nil { 184 | panic(err) 185 | } 186 | 187 | var add []string 188 | for _, id := range r.Form["songIdToAdd"] { 189 | add = append(add, tube.ParseSSID(id).ID) 190 | } 191 | 192 | rem := make(map[int]struct{}) 193 | for _, idx := range r.Form["songIndexToRemove"] { 194 | i, err := strconv.Atoi(idx) 195 | if err != nil { 196 | panic(err) 197 | } 198 | rem[i] = struct{}{} 199 | } 200 | 201 | lib, err := getLibrary(ctx, u) 202 | if err != nil { 203 | panic(err) 204 | } 205 | pl, err := tube.GetPlaylist(ctx, u.ID, pid) 206 | if err != nil { 207 | panic(err) 208 | } 209 | 210 | ids := make([]string, 0, len(pl.Tracks)) 211 | for i, id := range pl.Tracks { 212 | if _, ok := rem[i]; ok { 213 | continue 214 | } 215 | ids = append(ids, id) 216 | } 217 | ids = append(ids, add...) 218 | 219 | tracks := lib.TracksByID(ids) 220 | pl.With(tracks) 221 | 222 | if name != "" { 223 | pl.Name = name 224 | } 225 | if desc != "" { 226 | pl.Desc = desc 227 | } 228 | 229 | if err := pl.Save(ctx); err != nil { 230 | panic(err) 231 | } 232 | 233 | resp := struct { 234 | subsonicResponse 235 | Playlist subsonicPlaylist `xml:"playlist" json:"playlist"` 236 | }{ 237 | subsonicResponse: subOK(), 238 | Playlist: newSubsonicPlaylist(pl, tracks, u), 239 | } 240 | writeSubsonic(ctx, w, r, resp) 241 | } 242 | 243 | func subsonicDeletePlaylist(ctx context.Context, w http.ResponseWriter, r *http.Request) { 244 | u, _ := userFrom(ctx) 245 | pid, err := strconv.Atoi(r.FormValue("playlistId")) 246 | if err != nil { 247 | panic(err) 248 | } 249 | 250 | if err := tube.DeletePlaylist(ctx, u.ID, pid); err != nil { 251 | panic(err) 252 | } 253 | 254 | writeSubsonic(ctx, w, r, subOK()) 255 | } 256 | -------------------------------------------------------------------------------- /storage/s3.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/credentials" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/s3" 12 | ) 13 | 14 | var ( 15 | FilesBucket S3Bucket 16 | UploadsBucket S3Bucket 17 | CacheBucket S3Bucket 18 | ) 19 | 20 | type S3Bucket struct { 21 | S3 *s3.S3 22 | Name string 23 | Type StorageType 24 | } 25 | 26 | func (b S3Bucket) Put(contentType, key string, r io.ReadSeeker) error { 27 | _, err := b.S3.PutObject(&s3.PutObjectInput{ 28 | Body: r, 29 | Bucket: aws.String(b.Name), 30 | Key: aws.String(key), 31 | ContentType: aws.String(contentType), 32 | }) 33 | return err 34 | } 35 | 36 | func (b S3Bucket) PresignPut(key string, size int64, disp string, ttl time.Duration) (string, error) { 37 | req, _ := b.S3.PutObjectRequest(&s3.PutObjectInput{ 38 | Bucket: aws.String(b.Name), 39 | Key: aws.String(key), 40 | // ContentType: aws.String(contentType), 41 | ContentLength: aws.Int64(size), 42 | ContentDisposition: aws.String(disp), 43 | }) 44 | url, err := req.Presign(ttl) 45 | return url, err 46 | } 47 | 48 | func (b S3Bucket) PresignGet(key string, ttl time.Duration) (string, error) { 49 | req, _ := b.S3.GetObjectRequest(&s3.GetObjectInput{ 50 | Bucket: aws.String(b.Name), 51 | Key: aws.String(key), 52 | }) 53 | url, err := req.Presign(ttl) 54 | return url, err 55 | } 56 | 57 | func (b S3Bucket) Delete(key string) error { 58 | _, err := b.S3.DeleteObject(&s3.DeleteObjectInput{ 59 | Key: aws.String(key), 60 | }) 61 | return err 62 | } 63 | 64 | func (b S3Bucket) Keys() ([]string, error) { 65 | var keys []string 66 | err := b.S3.ListObjectsV2Pages(&s3.ListObjectsV2Input{Bucket: &b.Name}, func(out *s3.ListObjectsV2Output, _ bool) bool { 67 | for _, c := range out.Contents { 68 | keys = append(keys, *c.Key) 69 | } 70 | return true 71 | }) 72 | return keys, err 73 | } 74 | 75 | func (b S3Bucket) Get(key string) (io.ReadCloser, error) { 76 | out, err := b.S3.GetObject(&s3.GetObjectInput{Bucket: &b.Name, Key: &key}) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return out.Body, nil 81 | } 82 | 83 | func (b S3Bucket) Exists(key string) bool { 84 | _, err := b.S3.HeadObject(&s3.HeadObjectInput{Bucket: &b.Name, Key: &key}) 85 | // TODO actually check the error lol 86 | return err == nil 87 | } 88 | 89 | func (b S3Bucket) Copy(dst, src string) error { 90 | _, err := b.S3.CopyObject(&s3.CopyObjectInput{Bucket: &b.Name, CopySource: aws.String(b.Name + "/" + src), Key: &dst}) 91 | return err 92 | } 93 | 94 | func (b S3Bucket) CopyFromBucket(dst string, srcBucket S3Bucket, src string, mime, contentDisp string) error { 95 | copySrc := srcBucket.Name + "/" + src 96 | _, err := b.S3.CopyObject(&s3.CopyObjectInput{ 97 | Bucket: &b.Name, 98 | CopySource: ©Src, 99 | Key: &dst, 100 | ContentType: &mime, 101 | ContentDisposition: &contentDisp, 102 | }) 103 | return err 104 | } 105 | 106 | type S3Head struct { 107 | Type string 108 | Size int64 109 | } 110 | 111 | func (b S3Bucket) Head(key string) (S3Head, error) { 112 | head, err := b.S3.HeadObject(&s3.HeadObjectInput{Bucket: &b.Name, Key: &key}) 113 | if err != nil { 114 | return S3Head{}, err 115 | } 116 | ret := S3Head{} 117 | if head.ContentType != nil { 118 | ret.Type = *head.ContentType 119 | } 120 | if head.ContentLength != nil { 121 | ret.Size = *head.ContentLength 122 | } 123 | return ret, nil 124 | } 125 | 126 | func (b S3Bucket) List(prefix string) (map[string]S3Head, error) { 127 | objs := make(map[string]S3Head) 128 | err := b.S3.ListObjectsV2Pages(&s3.ListObjectsV2Input{ 129 | Bucket: aws.String(b.Name), 130 | Prefix: aws.String(prefix), 131 | }, func(out *s3.ListObjectsV2Output, _ bool) bool { 132 | for _, item := range out.Contents { 133 | objs[*item.Key] = S3Head{Size: *item.Size} 134 | } 135 | return true 136 | }) 137 | return objs, err 138 | } 139 | 140 | func newB2(region string, keyID, key string) *s3.S3 { 141 | endpoint := fmt.Sprintf("https://s3.%s.backblazeb2.com", region) 142 | return s3.New(session.Must(session.NewSession(&aws.Config{ 143 | Region: aws.String(region), 144 | Endpoint: aws.String(endpoint), 145 | Credentials: credentials.NewStaticCredentials(keyID, key, ""), 146 | S3ForcePathStyle: aws.Bool(true), 147 | Retryer: Retryer{}, 148 | }))) 149 | } 150 | 151 | func newR2(accountID string, keyID, key string) *s3.S3 { 152 | endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) 153 | return s3.New(session.Must(session.NewSession(&aws.Config{ 154 | Region: aws.String("auto"), 155 | Endpoint: aws.String(endpoint), 156 | Credentials: credentials.NewStaticCredentials(keyID, key, ""), 157 | Retryer: Retryer{}, 158 | }))) 159 | } 160 | 161 | func newS3(region, key, secret, endpoint string) *s3.S3 { 162 | cfg := &aws.Config{ 163 | Region: aws.String(region), 164 | Retryer: Retryer{}, 165 | } 166 | if key != "" && secret != "" { 167 | cfg.Credentials = credentials.NewStaticCredentials(key, secret, "") 168 | } 169 | if endpoint != "" { 170 | cfg.Endpoint = &endpoint 171 | cfg.S3ForcePathStyle = aws.Bool(true) 172 | } 173 | return s3.New(session.Must(session.NewSession(cfg))) 174 | } 175 | 176 | var ( 177 | S3Region = "us-west-2" 178 | S3Endpoint string 179 | 180 | S3AccessKeyID string 181 | S3AccessKeySecret string 182 | 183 | // for R2 184 | CFAccountID string 185 | ) 186 | 187 | type Config struct { 188 | Type StorageType 189 | 190 | FilesBucket string 191 | UploadsBucket string 192 | CacheBucket string 193 | 194 | Region string 195 | Endpoint string 196 | 197 | AccessKeyID string 198 | AccessKeySecret string 199 | 200 | // for R2 201 | CFAccountID string 202 | 203 | // for SQS 204 | SQSURL string 205 | SQSRegion string 206 | } 207 | 208 | type StorageType string 209 | 210 | const ( 211 | StorageTypeS3 StorageType = "s3" 212 | StorageTypeB2 StorageType = "b2" 213 | StorageTypeR2 StorageType = "r2" 214 | // StorageTypeFS StorageType = "fs" 215 | ) 216 | 217 | func Init(cfg Config) { 218 | var client *s3.S3 219 | awsClient := newS3("us-west-2", "", "", "") 220 | switch cfg.Type { 221 | case StorageTypeS3: 222 | client = newS3(cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Endpoint) 223 | case StorageTypeB2: 224 | client = newB2(cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret) 225 | case StorageTypeR2: 226 | client = newR2(cfg.CFAccountID, cfg.AccessKeyID, cfg.AccessKeySecret) 227 | case "": 228 | panic(fmt.Errorf("missing storage.type in configuration")) 229 | default: 230 | panic(fmt.Errorf("unknown storage.type in configuration: %q", cfg.Type)) 231 | } 232 | 233 | FilesBucket = S3Bucket{ 234 | Name: cfg.FilesBucket, 235 | S3: client, 236 | Type: cfg.Type, 237 | } 238 | 239 | UploadsBucket = S3Bucket{ 240 | Name: cfg.UploadsBucket, 241 | S3: client, 242 | Type: cfg.Type, 243 | } 244 | 245 | if cfg.CacheBucket != "" { 246 | CacheBucket = S3Bucket{ 247 | Name: cfg.CacheBucket, 248 | S3: awsClient, 249 | Type: StorageTypeS3, 250 | } 251 | } 252 | 253 | if cfg.SQSURL != "" { 254 | UseSQS(cfg.SQSRegion, cfg.SQSURL) 255 | } 256 | } 257 | 258 | func IsCacheEnabled() bool { 259 | return CacheBucket.Type != "" 260 | } 261 | -------------------------------------------------------------------------------- /assets/templates/subsonic.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "subsonic_title"}} 6 | 44 | 45 | 46 | {{render "_nav" $}} 47 |
48 |

{{tr "subsonic_title"}}

49 | 50 |

{{tr "subsonic_intro"}}

51 | 52 |
    53 |
  1. 54 | {{tr "subsonic_basic"}} 55 |
      56 |
    1. {{tr "subsonic_auth"}}
    2. 57 |
    3. {{tr "subsonic_support"}}
    4. 58 |
    59 |
  2. 60 |
  3. 61 | iOS 62 |
      63 |
    1. play:Sub
    2. 64 |
    65 |
  4. 66 |
  5. 67 | Android 68 |
      69 |
    1. Subtracks
    2. 70 |
    71 |
  6. 72 |
  7. 73 | Others 74 |
  8. 75 |
76 | 77 |

basic settings

78 | 79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 95 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | 108 | 109 |
{{tr "subsonic_settings"}}
{{tr "serveraddr"}}https://inter.tube
{{tr "username"}} 89 | {{with $.User.Email}} 90 | {{.}} 91 | {{else}} 92 | (your e-mail address) 93 | {{end}} 94 |
{{tr "password"}} 99 | (your inter.tube password) 100 |
{{tr "subsonic_authsetting"}} 105 | legacy mode, "force plain-text password", etc.
106 | (only required/exists for certain apps, see below) 107 |
110 | 111 |

{{tr "subsonic_auth"}}

112 |

・ inter.tube uses the "old style" of authentication that includes your password as a parameter in the URL. 113 |
・ the "new style" which sends a hash of your password and a salt is not supported because it would require us to store your password in plain text to authenticate you! 114 |
・ inter.tube securely hashses your password, so we require the old auth method. 115 |
・ it is mandatory to use HTTPS with inter.tube, this requirement keeps your password safe (apps that do not support HTTPS will not work) 116 |

117 | 118 |

{{tr "subsonic_support"}}

119 |

120 |

    121 |
  • 👍 basic stuff
  • 122 |
  • 👍 album art
  • 123 |
  • 👍 search
  • 124 |
  • 👍 sorting by recent, new, etc
  • 125 |
  • 👍 starring (favoriting)
  • 126 |
  • 👍 proper pagination
  • 127 |
  • 👍 playlists
  • 128 |
  • ❌ bookmarks (TODO)
  • 129 |
  • ❌ chat
  • 130 |
  • ❌ podcasts (let me know if you want it)
  • 131 |
  • ❌ similar artists (maybe?)
  • 132 |
  • ❌ lyrics
  • 133 |
  • ❌ last.fm integration (coming soon?)
  • 134 |
135 |

136 |
137 | 138 |

iOS

139 | 140 |

play:Sub

141 |

support status: excellent

142 |

download: app store link ($$$)

143 |

144 |

    145 |
  1. Tap the "play:Sub" menu icon on the bottom right.
  2. 146 |
  3. Tap the name of the server in the first menu item
  4. 147 |
  5. Tap "Selected server"
  6. 148 |
  7. Tap "Add server"
  8. 149 |
  9. Fill in the server address, server name, username, password as below
  10. 150 |
151 | 152 |

153 | 154 |
155 | 156 |

Android

157 | 158 |

Subtracks

159 |

support status: excellent? (need more testing)

160 |

download: play store link

161 |

162 |

    163 |
  1. Tap the Settings icon on the bottom right
  2. 164 |
  3. Tap Add server
  4. 165 |
  5. Fill in the server address, username, password as below
  6. 166 |
  7. Enable "force plain text password"
  8. 167 |
168 | 169 |

170 | 171 |

Others

172 | 173 |

many more apps are supported, tutorials coming soon(tm)

174 |
175 | 176 | -------------------------------------------------------------------------------- /web/upload.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "path" 12 | "strings" 13 | 14 | // "github.com/aws/aws-lambda-go/events" 15 | // "github.com/aws/aws-lambda-go/lambda" 16 | 17 | "github.com/guregu/tag" 18 | "github.com/hajimehoshi/go-mp3" 19 | "github.com/jfreymuth/oggvorbis" 20 | "github.com/mewkiz/flac" 21 | "golang.org/x/crypto/sha3" 22 | 23 | "github.com/guregu/intertube/storage" 24 | "github.com/guregu/intertube/tube" 25 | ) 26 | 27 | func uploadForm(ctx context.Context, w http.ResponseWriter, r *http.Request) { 28 | u, _ := userFrom(ctx) 29 | 30 | // test lol 31 | tracks, err := u.GetTracks(ctx) 32 | if err != nil { 33 | panic(err) 34 | } 35 | lib := NewLibrary(tracks, nil) 36 | type meta struct { 37 | LastMod int64 38 | Size int 39 | } 40 | dupes := make(map[string][]meta) 41 | for _, t := range lib.Tracks(organize{}) { 42 | dupes[t.Filename] = append(dupes[t.Filename], meta{ 43 | Size: t.Size, 44 | LastMod: t.LocalMod, 45 | }) 46 | } 47 | 48 | data := struct { 49 | User tube.User 50 | Dupes map[string][]meta 51 | }{ 52 | User: u, 53 | Dupes: dupes, 54 | } 55 | renderTemplate(ctx, w, "upload", data, http.StatusOK) 56 | } 57 | 58 | func handleUpload(ctx context.Context, key string, user tube.User, b2ID string) (tube.Track, error) { 59 | id := path.Base(key) 60 | 61 | fmeta, err := tube.GetFile(ctx, id) 62 | if err != nil { 63 | return tube.Track{}, err 64 | } 65 | 66 | if fmeta.TrackID != "" { 67 | log.Println("already exists?", fmeta.TrackID) 68 | track, err := tube.GetTrack(ctx, user.ID, fmeta.TrackID) 69 | if err == nil { 70 | return track, nil 71 | } 72 | log.Println("error getting pre-existing track:", err) 73 | } 74 | 75 | log.Println("get file ...") 76 | 77 | r, err := storage.UploadsBucket.Get(key) 78 | if err != nil { 79 | return tube.Track{}, err 80 | } 81 | defer r.Close() 82 | 83 | var buf bytes.Buffer 84 | if _, err := io.Copy(&buf, r); err != nil { 85 | return tube.Track{}, err 86 | } 87 | raw := bytes.NewReader(buf.Bytes()) 88 | 89 | _, format, err := tag.Identify(raw) 90 | if err != tag.ErrNoTagsFound && err != nil { 91 | return tube.Track{}, err 92 | } 93 | // fmt.Println("GOT:", format, noTags) 94 | if format == tag.UnknownFileType { 95 | switch strings.ToLower(path.Ext(fmeta.Name)) { 96 | case ".mp3": 97 | format = tag.MP3 98 | case ".flac": 99 | format = tag.FLAC 100 | case ".m4a": 101 | format = tag.M4A 102 | case ".ogg": 103 | format = tag.OGG 104 | } 105 | } 106 | if format != tag.MP3 && format != tag.FLAC && format != tag.M4A && format != tag.OGG { 107 | return tube.Track{}, fmt.Errorf("only mp3/flac/m4a supported right now (got: %v)", format) 108 | } 109 | raw.Seek(0, io.SeekStart) 110 | 111 | log.Println("calcDuration ...") 112 | 113 | dur, err := calcDuration(raw, format) 114 | if err != nil && !skippableError(err) { 115 | return tube.Track{}, err 116 | } 117 | raw.Seek(0, io.SeekStart) 118 | 119 | // var tags tag.Metadata 120 | var tags multiMeta 121 | if format == tag.OGG { 122 | if got, err := tag.ReadOGGTags(raw); err == nil { 123 | tags = append(tags, got) 124 | } 125 | raw.Seek(0, io.SeekStart) 126 | } 127 | if got, err := tag.ReadID3v2Tags(raw); err == nil { 128 | tags = append(tags, got) 129 | } 130 | // spew.Dump(tags) 131 | raw.Seek(0, io.SeekStart) 132 | if got, err := tag.ReadFrom(raw); err == nil { 133 | tags = append(tags, got) 134 | } 135 | raw.Seek(0, io.SeekStart) 136 | tags = append(tags, guessMetadata(fmeta.Name, format)) 137 | unfuckID3(tags) 138 | raw.Seek(0, io.SeekStart) 139 | 140 | log.Println("tag.SumAll ...") 141 | 142 | sum, err := tag.SumAll(raw) 143 | if err != nil { 144 | return tube.Track{}, err 145 | } 146 | 147 | trackInfo := tube.TrackInfo{ 148 | Title: tags.Title(), 149 | Artist: tags.Artist(), 150 | Album: tags.Album(), 151 | AlbumArtist: tags.AlbumArtist(), 152 | Composer: tags.Composer(), 153 | Genre: tags.Genre(), 154 | Comment: tags.Comment(), 155 | } 156 | trackInfo.Sanitize() 157 | 158 | // TODO: don't need this anyway? 159 | // meta := copyTags(tags.Raw(), "PIC", "APIC", "PIC\u0000") 160 | track := tube.Track{ 161 | UserID: fmeta.UserID, 162 | ID: sum, 163 | 164 | Year: tags.Year(), 165 | 166 | Filename: strings.ToValidUTF8(fmeta.Name, replacementChar), 167 | Filetype: string(tags.FileType()), 168 | UploadID: fmeta.ID, 169 | Size: buf.Len(), 170 | LocalMod: fmeta.LocalMod, 171 | Duration: dur, 172 | 173 | TagFormat: string(tags.Format()), 174 | // Metadata: meta, 175 | } 176 | track.Number, track.Total = tags.Track() 177 | track.Disc, track.Discs = tags.Disc() 178 | track.ApplyInfo(trackInfo) 179 | 180 | log.Println("copyUploadToFiles ...") 181 | err = copyUploadToFiles(ctx, track.StorageKey(), b2ID, fmeta) 182 | if err != nil { 183 | return tube.Track{}, err 184 | } 185 | 186 | if pic := tags.Picture(); pic != nil { 187 | log.Println("savePic ...") 188 | track.Picture, err = savePic(pic.Data, pic.Ext, pic.Type, pic.Description) 189 | if err != nil { 190 | return tube.Track{}, err 191 | } 192 | } 193 | 194 | log.Println("track.Create ...") 195 | 196 | if err := track.Create(ctx); err != nil { 197 | return tube.Track{}, err 198 | } 199 | 200 | log.Println("SetTrackID ...") 201 | 202 | if err := fmeta.SetTrackID(track.ID); err != nil { 203 | return tube.Track{}, err 204 | } 205 | 206 | return track, nil 207 | } 208 | 209 | var replacementChar = "�" 210 | 211 | func savePic(data []byte, ext string, mimetype string, desc string) (tube.Picture, error) { 212 | id, err := sha3Sum(data) 213 | if err != nil { 214 | return tube.Picture{}, err 215 | } 216 | pic := tube.Picture{ 217 | ID: id, 218 | Ext: ext, 219 | Type: mimetype, 220 | Desc: desc, 221 | } 222 | err = storage.FilesBucket.Put(mimetype, pic.StorageKey(), bytes.NewReader(data)) 223 | return pic, err 224 | } 225 | 226 | // for images 227 | func sha3Sum(b []byte) (string, error) { 228 | sum := sha3.Sum224(b) 229 | str := base64.RawURLEncoding.EncodeToString(sum[:]) 230 | return str, nil 231 | } 232 | 233 | // secs 234 | func calcDuration(r io.ReadSeeker, ftype tag.FileType) (int, error) { 235 | switch ftype { 236 | case tag.MP3: 237 | dec, err := mp3.NewDecoder(r) 238 | if err != nil { 239 | if strings.Contains(err.Error(), "free bitrate") { 240 | return 0, nil 241 | } 242 | return 0, err 243 | } 244 | sr := dec.SampleRate() 245 | length := dec.Length() 246 | if sr == 0 { 247 | return 0, nil 248 | } 249 | return (int(length) / sr) / 4, nil 250 | case tag.FLAC: 251 | stream, err := flac.Parse(r) 252 | if err != nil { 253 | return 0, err 254 | } 255 | defer stream.Close() 256 | sec := stream.Info.NSamples / uint64(stream.Info.SampleRate) 257 | return int(sec), nil 258 | case tag.M4A: 259 | // TODO: need to find a go library with a proper license that parses these 260 | return 0, nil 261 | // secs, err := mp4util.Duration(r) 262 | // if err != nil { 263 | // return 0, err 264 | // } 265 | // return secs, nil 266 | case tag.OGG: 267 | length, format, err := oggvorbis.GetLength(r) 268 | if err != nil { 269 | // TODO: verify 270 | log.Println("OGG ERROR:", err) 271 | return 0, nil 272 | } 273 | sec := length / int64(format.SampleRate) 274 | return int(sec), nil 275 | } 276 | return 0, fmt.Errorf("unknown type: %v", ftype) 277 | } 278 | 279 | func skippableError(err error) bool { 280 | if err == nil { 281 | return true 282 | } 283 | str := err.Error() 284 | // mp3 package chokes on certain files, so let it fail 285 | return strings.Contains(str, "mp3:") 286 | } 287 | -------------------------------------------------------------------------------- /web/metadata.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strconv" 7 | "strings" 8 | "unicode/utf8" 9 | 10 | "github.com/guregu/tag" 11 | ) 12 | 13 | type guessedMeta struct { 14 | ftype tag.FileType 15 | title string 16 | album string 17 | artist string 18 | albumArtist string 19 | track int 20 | disc int 21 | } 22 | 23 | func guessMetadata(name string, ftype tag.FileType) tag.Metadata { 24 | name = strings.TrimSuffix(name, path.Ext(name)) 25 | if !strings.ContainsRune(name, ' ') { 26 | name = strings.ReplaceAll(name, "_", " ") 27 | } 28 | meta := guessedMeta{ 29 | ftype: ftype, 30 | } 31 | parts := strings.Split(name, "-") 32 | var nums []int 33 | var strs []string 34 | for _, p := range parts { 35 | p = strings.TrimSpace(p) 36 | if n, err := strconv.Atoi(p); err == nil { 37 | nums = append(nums, n) 38 | continue 39 | } 40 | strs = append(strs, p) 41 | } 42 | 43 | if len(strs) == 0 { 44 | return guessedMeta{title: name, ftype: ftype} 45 | } 46 | 47 | // title + maybe track number 48 | // TODO: rewrite lol 49 | last := strs[len(strs)-1] 50 | if lsplit := strings.Split(last, " "); len(lsplit) >= 2 { 51 | maybeTrack := strings.TrimSuffix(lsplit[0], ".") 52 | if strings.ContainsRune(maybeTrack, '-') { 53 | nsplit := strings.Split(maybeTrack, "-") 54 | d, err1 := strconv.Atoi(nsplit[0]) 55 | n, err2 := strconv.Atoi(nsplit[1]) 56 | fmt.Println(d, err1, n, err2) 57 | if err1 == nil && err2 == nil { 58 | meta.disc = d 59 | meta.track = n 60 | meta.title = strings.Join(lsplit[1:], " ") 61 | } else { 62 | meta.title = last 63 | } 64 | } else if n, err := strconv.Atoi(maybeTrack); err == nil { 65 | meta.track = n 66 | meta.title = strings.Join(lsplit[1:], " ") 67 | } else { 68 | meta.title = last 69 | } 70 | } else { 71 | meta.title = strs[len(strs)-1] 72 | } 73 | 74 | if len(nums) > 0 { 75 | lastnum := nums[len(nums)-1] 76 | if meta.track != 0 { 77 | meta.disc = lastnum 78 | } else { 79 | meta.track = lastnum 80 | } 81 | // if meta.title == "" && len(nums) == 2 { 82 | // meta.title = strconv.Itoa(lastnum) 83 | // meta.track = nums[0] 84 | // } 85 | } 86 | 87 | switch len(strs) { 88 | case 1: 89 | case 2: 90 | meta.artist = strs[0] 91 | case 3: 92 | meta.artist = strs[0] 93 | meta.album = strs[1] 94 | case 4: 95 | meta.albumArtist = strs[0] 96 | meta.album = strs[1] 97 | meta.artist = strs[2] 98 | default: 99 | // give up 100 | meta.title = name 101 | } 102 | 103 | return meta 104 | } 105 | 106 | func (m guessedMeta) Format() tag.Format { return tag.UnknownFormat } 107 | func (m guessedMeta) FileType() tag.FileType { return m.ftype } 108 | func (m guessedMeta) Title() string { return m.title } 109 | func (m guessedMeta) Album() string { return m.album } 110 | func (m guessedMeta) Artist() string { return m.artist } 111 | func (m guessedMeta) Track() (int, int) { return m.track, 0 } 112 | func (m guessedMeta) Disc() (int, int) { return m.disc, 0 } 113 | func (m guessedMeta) AlbumArtist() string { return "" } 114 | func (m guessedMeta) Composer() string { return "" } 115 | func (m guessedMeta) Year() int { return 0 } 116 | func (m guessedMeta) Genre() string { return "" } 117 | func (m guessedMeta) Picture() *tag.Picture { return nil } 118 | func (m guessedMeta) Lyrics() string { return "" } 119 | func (m guessedMeta) Comment() string { return "" } 120 | func (m guessedMeta) Raw() map[string]interface{} { return map[string]interface{}{} } 121 | 122 | type multiMeta []tag.Metadata 123 | 124 | func (m multiMeta) Format() tag.Format { 125 | for _, child := range m { 126 | if f := child.Format(); f != "" { 127 | return f 128 | } 129 | } 130 | return tag.UnknownFormat 131 | } 132 | 133 | func (m multiMeta) FileType() tag.FileType { 134 | for _, child := range m { 135 | if f := child.FileType(); f != "" && f != tag.UnknownFileType { 136 | return f 137 | } 138 | } 139 | return tag.UnknownFileType 140 | } 141 | 142 | func (m multiMeta) Title() string { 143 | return m.try(func(meta tag.Metadata) string { return meta.Title() }) 144 | } 145 | 146 | func (m multiMeta) Album() string { 147 | return m.try(func(meta tag.Metadata) string { return meta.Album() }) 148 | } 149 | 150 | func (m multiMeta) Artist() string { 151 | return m.try(func(meta tag.Metadata) string { return meta.Artist() }) 152 | } 153 | 154 | func (m multiMeta) AlbumArtist() string { 155 | return m.try(func(meta tag.Metadata) string { return meta.AlbumArtist() }) 156 | } 157 | 158 | func (m multiMeta) Composer() string { 159 | return m.try(func(meta tag.Metadata) string { return meta.Composer() }) 160 | } 161 | 162 | func (m multiMeta) Genre() string { 163 | return m.try(func(meta tag.Metadata) string { return meta.Genre() }) 164 | } 165 | 166 | func (m multiMeta) Lyrics() string { 167 | return m.try(func(meta tag.Metadata) string { return meta.Lyrics() }) 168 | } 169 | func (m multiMeta) Comment() string { 170 | return m.try(func(meta tag.Metadata) string { return meta.Comment() }) 171 | } 172 | 173 | func (m multiMeta) Track() (int, int) { 174 | for _, child := range m { 175 | a, b := child.Track() 176 | if a != 0 || b != 0 { 177 | return a, b 178 | } 179 | } 180 | return 0, 0 181 | } 182 | 183 | func (m multiMeta) Disc() (int, int) { 184 | for _, child := range m { 185 | a, b := child.Disc() 186 | if a != 0 || b != 0 { 187 | return a, b 188 | } 189 | } 190 | return 0, 0 191 | } 192 | 193 | func (m multiMeta) Year() int { 194 | for _, child := range m { 195 | x := child.Year() 196 | if x != 0 { 197 | return x 198 | } 199 | } 200 | return 0 201 | } 202 | func (m multiMeta) Picture() *tag.Picture { 203 | for _, child := range m { 204 | x := child.Picture() 205 | if x != nil { 206 | return x 207 | } 208 | } 209 | return nil 210 | } 211 | 212 | func (m multiMeta) Raw() map[string]interface{} { 213 | tags := map[string]interface{}{} 214 | for _, child := range m { 215 | if len(child.Raw()) > len(tags) { 216 | tags = child.Raw() 217 | } 218 | } 219 | return tags 220 | } 221 | 222 | func (m multiMeta) try(get func(tag.Metadata) string) string { 223 | var invalid string 224 | for _, child := range m { 225 | if str := get(child); str != "" { 226 | if !utf8.ValidString(str) { 227 | invalid = str 228 | continue 229 | } 230 | return str 231 | } 232 | } 233 | if invalid != "" { 234 | if valid := strings.ToValidUTF8(invalid, ""); valid != "" { 235 | return valid 236 | } 237 | } 238 | return "" 239 | } 240 | 241 | var id3v1to2 = map[string]string{ 242 | "TT2": "TIT2", 243 | "TP1": "TPE1", 244 | "TAL": "TALB", 245 | "TP2": "TPE2", 246 | "TCM": "TCOM", 247 | "TYE": "TYER", 248 | "TRK": "TRCK", 249 | "TPA": "TPOS", 250 | "TCO": "TCON", 251 | // "PIC": "APIC", 252 | // "": "USLT", 253 | // "COM": "COMM", // panics on *tag.Comm conversion 254 | } 255 | 256 | // unfuckID3 fixes the given metadata if it is ID3v2 format but with ID3v1 keys 257 | // (yes, such terrible files actually exist) 258 | func unfuckID3(metadata tag.Metadata) { 259 | if metadata.Format() != tag.ID3v2_3 { 260 | return 261 | } 262 | raw := metadata.Raw() 263 | for k, v := range raw { 264 | if k == "PIC\u0000" { 265 | pic, err := tag.ReadPICFrame(v.([]byte)) 266 | if err == nil { 267 | raw["APIC"] = pic 268 | } 269 | delete(raw, k) 270 | continue 271 | } 272 | if len(k) == 4 && k[3] == 0 { 273 | if fixed, ok := id3v1to2[k[:3]]; ok { 274 | raw[fixed] = v 275 | delete(raw, k) 276 | } 277 | } 278 | } 279 | } 280 | 281 | var ( 282 | _ tag.Metadata = guessedMeta{} 283 | _ tag.Metadata = multiMeta{} 284 | ) 285 | -------------------------------------------------------------------------------- /web/file.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/guregu/kami" 15 | 16 | "github.com/guregu/intertube/storage" 17 | "github.com/guregu/intertube/tube" 18 | ) 19 | 20 | const ( 21 | maxFileSize = 1024 * 1024 * 1024 // 1GB 22 | 23 | fileDownloadTTL = 1 * time.Hour 24 | thumbnailDownloadTTL = 1 * time.Hour 25 | uploadTTL = 4 * time.Hour 26 | ) 27 | 28 | func downloadTrack(ctx context.Context, w http.ResponseWriter, r *http.Request) { 29 | u, _ := userFrom(ctx) 30 | 31 | id := kami.Param(ctx, "id") 32 | if ext := path.Ext(id); ext != "" { 33 | id = id[:len(id)-len(ext)] 34 | } 35 | 36 | f, err := tube.GetTrack(ctx, u.ID, id) 37 | if err == tube.ErrNotFound { 38 | http.NotFound(w, r) 39 | return 40 | } 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | href, err := storage.FilesBucket.PresignGet(f.StorageKey(), fileDownloadTTL) 46 | if err != nil { 47 | panic(err) 48 | } 49 | http.Redirect(w, r, href, http.StatusTemporaryRedirect) 50 | } 51 | 52 | func uploadStart(ctx context.Context, w http.ResponseWriter, r *http.Request) { 53 | u, _ := userFrom(ctx) 54 | name := r.FormValue("name") 55 | filetype := r.FormValue("type") 56 | size, err := strconv.ParseInt(r.FormValue("size"), 10, 64) 57 | if err != nil { 58 | panic(err) 59 | } 60 | if size == 0 { 61 | panic("missing file size") 62 | } 63 | var localMod int64 64 | if msec, err := strconv.ParseInt(r.FormValue("lastmod"), 10, 64); err == nil { 65 | localMod = msec 66 | } 67 | 68 | w.Header().Set("Tube-Upload-Usage", strconv.FormatInt(u.Usage, 10)) 69 | w.Header().Set("Tube-Upload-Quota", strconv.FormatInt(u.CalcQuota(), 10)) 70 | if size > maxFileSize { 71 | w.WriteHeader(400) 72 | fmt.Fprintln(w, "file too big. max size is "+strconv.FormatInt(maxFileSize/1000/1000, 10)+"MB") 73 | return 74 | } 75 | if (u.CalcQuota() != 0) && (u.Usage+size > u.CalcQuota()) { 76 | w.WriteHeader(400) 77 | fmt.Fprintln(w, "upload quota exceeded") 78 | return 79 | } 80 | 81 | zf := tube.NewFile(u.ID, name, size) 82 | zf.Type = filetype // TODO 83 | zf.LocalMod = localMod 84 | if err := zf.Create(ctx); err != nil { 85 | panic(err) 86 | } 87 | 88 | if storage.UploadsBucket.Exists(zf.Path()) { 89 | panic("already exists?!") 90 | } 91 | 92 | disp := encodeContentDisp(name) 93 | url, err := storage.UploadsBucket.PresignPut(zf.Path(), size, disp, uploadTTL) 94 | if err != nil { 95 | panic(err) 96 | } 97 | 98 | var data = struct { 99 | ID string 100 | CD string 101 | URL string 102 | Token string 103 | }{ 104 | ID: zf.ID, 105 | CD: disp, 106 | URL: url, 107 | } 108 | 109 | w.Header().Set("Tube-Upload-ID", zf.ID) 110 | renderJSON(w, data, http.StatusOK) 111 | } 112 | 113 | func uploadStart2(ctx context.Context, w http.ResponseWriter, r *http.Request) { 114 | u, _ := userFrom(ctx) 115 | 116 | var input []struct { 117 | Name string 118 | Type string // mimetype 119 | Size int64 120 | LocalMod int64 `json:"lastmod"` 121 | } 122 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 123 | panic(err) 124 | } 125 | 126 | type meta struct { 127 | ID string 128 | CD string 129 | URL string 130 | } 131 | output := make([]meta, 0, len(input)) 132 | 133 | var totalsize int64 134 | for _, f := range input { 135 | if f.Size == 0 { 136 | panic("missing file size") 137 | } 138 | if f.Size > maxFileSize { 139 | w.WriteHeader(400) 140 | fmt.Fprintln(w, "file too big. max size is "+strconv.FormatInt(maxFileSize/1024/1024, 10)+"MB") 141 | return 142 | } 143 | totalsize += f.Size 144 | 145 | zf := tube.NewFile(u.ID, f.Name, f.Size) 146 | zf.Type = f.Type 147 | zf.LocalMod = f.LocalMod 148 | if err := zf.Create(ctx); err != nil { 149 | panic(err) 150 | } 151 | 152 | if storage.UploadsBucket.Exists(zf.Path()) { 153 | panic("already exists?! " + zf.ID) 154 | } 155 | 156 | disp := encodeContentDisp(f.Name) 157 | url, err := storage.UploadsBucket.PresignPut(zf.Path(), f.Size, disp, uploadTTL) 158 | if err != nil { 159 | panic(err) 160 | } 161 | 162 | output = append(output, meta{ 163 | ID: zf.ID, 164 | CD: disp, 165 | URL: url, 166 | }) 167 | } 168 | 169 | if quota := u.CalcQuota(); quota != 0 { 170 | if u.Usage+totalsize > quota { 171 | renderText(w, "file would exceed upload quota", http.StatusBadRequest) 172 | return 173 | } 174 | } 175 | 176 | w.Header().Set("Tube-Upload-Usage", strconv.FormatInt(u.Usage, 10)) 177 | w.Header().Set("Tube-Upload-Quota", strconv.FormatInt(u.CalcQuota(), 10)) 178 | renderJSON(w, output, http.StatusOK) 179 | } 180 | 181 | func ProcessUpload(ctx context.Context, f *tube.File, u tube.User, uploadPath string) (tube.Track, error) { 182 | if f.Deleted || f.UserID != u.ID { 183 | return tube.Track{}, fmt.Errorf("forbidden") 184 | } 185 | 186 | if err := f.SetStarted(ctx, time.Now().UTC()); err != nil { 187 | return tube.Track{}, err 188 | } 189 | 190 | head, err := storage.UploadsBucket.Head(f.Path()) 191 | if err != nil { 192 | return tube.Track{}, fmt.Errorf("file not found in storage") 193 | } 194 | if err := f.Finish(ctx, head.Type, head.Size); err != nil { 195 | return tube.Track{}, err 196 | } 197 | if head.Size > maxFileSize { 198 | storage.FilesBucket.Delete(f.Path()) 199 | return tube.Track{}, fmt.Errorf("file too big") 200 | } 201 | 202 | track, err := handleUpload(ctx, f.Path(), u, uploadPath) 203 | if err != nil { 204 | return track, err 205 | } 206 | if err := u.UpdateLastMod(ctx); err != nil { 207 | return track, err 208 | } 209 | return track, nil 210 | } 211 | 212 | func uploadFinish(ctx context.Context, w http.ResponseWriter, r *http.Request) { 213 | u, ok := userFrom(ctx) 214 | if !ok { 215 | panic("no account") 216 | } 217 | bID := r.URL.Query().Get("bid") 218 | if bID == "" { 219 | panic("no bid") 220 | } 221 | 222 | id := kami.Param(ctx, "id") 223 | f, err := tube.GetFile(ctx, id) 224 | if err != nil { 225 | panic(err) 226 | } 227 | 228 | if f.Ready && f.TrackID != "" { 229 | track, err := tube.GetTrack(ctx, u.ID, f.TrackID) 230 | if err != nil { 231 | panic(err) 232 | } 233 | if err := json.NewEncoder(w).Encode(&track); err != nil { 234 | panic(err) 235 | } 236 | return 237 | } 238 | 239 | if !storage.UsingQueue() { 240 | track, err := ProcessUpload(ctx, &f, u, bID) 241 | if err != nil { 242 | panic(err) 243 | } 244 | if err := json.NewEncoder(w).Encode(&track); err != nil { 245 | panic(err) 246 | } 247 | return 248 | } 249 | 250 | if f.Queued.IsZero() { 251 | err = storage.EnqueueFile(storage.FileEvent{ 252 | FileID: f.ID, 253 | UserID: u.ID, 254 | Path: bID, 255 | }) 256 | if err != nil { 257 | panic(err) 258 | } 259 | if err := f.SetQueued(ctx, time.Now().UTC()); err != nil { 260 | panic(err) 261 | } 262 | } 263 | 264 | w.Header().Set("Tube-Upload-Status", f.Status()) 265 | w.WriteHeader(http.StatusAccepted) 266 | if err := json.NewEncoder(w).Encode(f); err != nil { 267 | panic(err) 268 | } 269 | } 270 | 271 | func encodeContentDisp(filename string) string { 272 | ext := path.Ext(filename) 273 | // return "attachment; filename*=UTF-8''" + url.PathEscape(filename) 274 | escaped := url.QueryEscape(filename) 275 | escaped = strings.ReplaceAll(escaped, "+", "%20") 276 | return "attachment; filename=\"file" + ext + "\"; filename*=UTF-8''" + escaped 277 | } 278 | 279 | func copyUploadToFiles(ctx context.Context, dstPath string, fileID string, f tube.File) error { 280 | disp := "attachment; filename*=UTF-8''" + escapeFilename(f.Name) 281 | return storage.FilesBucket.CopyFromBucket(dstPath, storage.UploadsBucket, f.Path(), f.Type, disp) 282 | } 283 | 284 | func presignTrackDL(_ tube.User, track tube.Track) string { 285 | href, err := storage.FilesBucket.PresignGet(track.StorageKey(), fileDownloadTTL*2) 286 | if err != nil { 287 | panic(err) 288 | } 289 | return href 290 | } 291 | 292 | func escapeFilename(name string) string { 293 | const illegal = `<>@,;:\"/+[]?={} ` 294 | name = strings.Map(func(r rune) rune { 295 | if strings.ContainsRune(illegal, r) { 296 | return '-' 297 | } 298 | return r 299 | }, name) 300 | name = url.PathEscape(name) 301 | if len(name) == 0 { 302 | return "file" 303 | } 304 | return name 305 | } 306 | -------------------------------------------------------------------------------- /assets/templates/settings.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{render "_head" $}} 5 | {{tr "titleprefix"}}{{tr "settings_title"}} 6 | 46 | 47 | 48 | {{render "_nav" $}} 49 |
50 |

{{tr "settings_title"}}

51 |

{{tr "settings_intro"}}

52 |

{{$.ErrorMsg}}

53 |
54 | {{$opt := $.User.Display}} 55 | 56 | 57 | 60 | 61 | {{if payment}} 62 | {{if $.User.Trialing}} 63 | 64 | 65 | 69 | 70 | 71 | 72 | 80 | 81 | 82 | {{else}} 83 | 84 | {{if $.HasSub}} 85 | 86 | 87 | 88 | 89 | {{end}} 90 | 91 | {{if (not $.HasSub)}} 92 | 93 | 97 | 98 | {{else if $.User.Canceled}} 99 | 100 | 101 | {{else}} 102 | 103 | 104 | {{end}} 105 | 106 | 107 | 114 | 118 | 119 | 120 | {{end}} 121 | {{end}} 122 | 123 | {{$quota := $.User.CalcQuota}} 124 | {{if $.User.StorageFull}} 125 | 126 | 127 | 131 | 132 | 133 | 134 | 135 | {{else}} 136 | 137 | 138 | 142 | 143 | {{end}} 144 | 145 | 146 | {{if $.CacheEnabled}} 147 | 148 | 149 | 150 | 151 | 152 | 153 | 157 | 158 | 159 | {{end}} 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 206 | 207 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |
58 | {{if payment}}{{tr "settings_subscription"}}{{else}}{{tr "quota"}}{{end}} 59 |
66 | {{tr "settings_trialexplain"}}
67 | ☛ {{tr "settings_buylink"}} 💸 68 |
{{tr "settings_trialexpires"}}: 73 | {{if $.User.PlanExpire.IsZero}} 74 | {{tr "settings_grandfathered"}} 75 | {{else}} 76 | {{if $.User.Expired}}⚠️ {{tr "expired"}} {{end}} 77 | {{$.User.PlanExpire | timestamp}} 78 | {{end}} 79 |
:{{tr $.User.Plan.Msg}} ({{tr "settings_update"}})
94 | {{tr "settings_expired"}}
95 | ☛ {{tr "settings_buylink"}} 💸 96 |
{{tr "settings_standing"}}:⚠️ {{tr "canceled"}} ({{tr "settings_renew"}}){{tr "settings_standing"}}:{{$.User.PlanStatus}} ({{if (eq $.User.PlanStatus "active")}}{{tr "settings_cancel"}}{{else}}{{tr "settings_renew"}}{{end}})
108 | {{if $.User.Canceled}} 109 | {{tr "settings_expires"}}: 110 | {{else}} 111 | {{tr "settings_nextdue"}}: 112 | {{end}} 113 | 115 | {{if $.User.Expired}}⏰{{end}} 116 | {{$.User.PlanExpire | date}} 117 |
{{tr "settings_usage"}}: 128 | ⚠️ {{$.User.Usage | bytesize}} / {{$quota | bytesize}} ({{$.User.UsageDesc}}%)
129 | {{render "_quota" $}} 130 |
{{tr "settings_storagefull"}}
{{tr "settings_usage"}}: 139 | {{$.User.Usage | bytesize}} / {{$quota | bytesize}} ({{$.User.UsageDesc}}%)
140 | {{render "_quota" $}} 141 |
{{tr "settings_library"}}
: 154 | {{$.User.LastDump | timestamp}} 155 | 156 |
{{tr "settings_account"}}
:
:{{tr "settings_changepass"}}
{{tr "settings_display"}}
: 186 | 192 |
:
: 201 | 205 |
224 |
225 |
226 |

{{tr "settings_actions"}}

227 |
    228 | {{if payment}} 229 |
  • 230 |
    231 |
  • 232 | {{end}} 233 |
  • 234 |
    235 |
  • 236 |
237 |
238 | 239 | -------------------------------------------------------------------------------- /cmd/tubesync/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.44.223/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 2 | github.com/aws/aws-sdk-go v1.44.289 h1:5CVEjiHFvdiVlKPBzv0rjG4zH/21W/onT18R5AH/qx0= 3 | github.com/aws/aws-sdk-go v1.44.289/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 4 | github.com/aws/aws-sdk-go v1.52.2 h1:l4g9wBXRBlvCtScvv4iLZCzLCtR7BFJcXOnOGQ20orw= 5 | github.com/aws/aws-sdk-go v1.52.2/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 6 | github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 7 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 8 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 10 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/guregu/dynamo v1.19.0 h1:MA7KsSxmzGqd/xTddjqMAD0TyWcG6HOk5zoMOmjsxR8= 15 | github.com/guregu/dynamo v1.19.0/go.mod h1:A0OqisWkmE7k8CZSA/gwT+iDhEmYBTcdXC1A17LpKH4= 16 | github.com/guregu/dynamo v1.22.2 h1:Hd4xMFgSFz2YFmiY+lIIJflHSHsrxMljOOxYvZBbx08= 17 | github.com/guregu/dynamo v1.22.2/go.mod h1:a0knvVZrDhT+q7eQlu1n041lf5vPi0sNfGjRh81mAnQ= 18 | github.com/guregu/intertube v0.0.0-20230906163359-85e3af45f847 h1:7iEG4cfR8HKjMhf/LxBG0UAQaeSQl2DG7rSDjttdK4k= 19 | github.com/guregu/intertube v0.0.0-20230906163359-85e3af45f847/go.mod h1:5FRokjyvycCw/8HOwfJBgpPKd8gRmWJWuHcnC/sNgO8= 20 | github.com/guregu/intertube v0.0.0-20240504223008-df244dad8902 h1:aNmMMiUa8h8kxTVUFQGkRsaaXfmmbwz3uNhEsH2rFDw= 21 | github.com/guregu/intertube v0.0.0-20240504223008-df244dad8902/go.mod h1:Sj/RFFRbGxoncmOra+LFljCMQsgqC6N+50wJIpu/5MM= 22 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 23 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 24 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 25 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 26 | github.com/karlseguin/ccache/v2 v2.0.7 h1:y5Pfi4eiyYCOD6LS/Kj+o6Nb4M5Ngpw9qFQs+v44ZYM= 27 | github.com/karlseguin/ccache/v2 v2.0.7/go.mod h1:2BDThcfQMf/c0jnZowt16eW405XIqZPavt+HoYEtcxQ= 28 | github.com/karlseguin/ccache/v2 v2.0.8 h1:lT38cE//uyf6KcFok0rlgXtGFBWxkI6h/qg4tbFyDnA= 29 | github.com/karlseguin/ccache/v2 v2.0.8/go.mod h1:2BDThcfQMf/c0jnZowt16eW405XIqZPavt+HoYEtcxQ= 30 | github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= 31 | github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= 32 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 36 | github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= 37 | github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= 38 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 39 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 40 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 41 | golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= 42 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 43 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 44 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 45 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 46 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 47 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 48 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 49 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 50 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 51 | golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= 52 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 53 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 54 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 55 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 56 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 58 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 59 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 60 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 69 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 71 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 73 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 74 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 75 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 76 | golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= 77 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 78 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= 79 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 80 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 81 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 82 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 83 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 84 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 85 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 86 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 87 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 88 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 89 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 90 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 93 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 94 | -------------------------------------------------------------------------------- /cmd/tubesync/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/http/cookiejar" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "sync" 16 | "sync/atomic" 17 | "syscall" 18 | 19 | "golang.org/x/net/publicsuffix" 20 | "golang.org/x/term" 21 | 22 | "github.com/guregu/intertube/tube" 23 | ) 24 | 25 | const ( 26 | version = "0.1.0" 27 | ) 28 | 29 | var client *http.Client 30 | var rootPath string 31 | 32 | var ( 33 | parallel = flag.Int("parallel", 10, "number of simultaneous downloads") 34 | host = flag.String("host", "https://inter.tube", "host URL, change for custom deployments") 35 | workDir = flag.String("path", "", "path to music library directory, leave blank (default) for current directory") 36 | help = flag.Bool("help", false, "show this help message") 37 | ) 38 | 39 | var ErrSkip = fmt.Errorf("skipped") 40 | 41 | func main() { 42 | flag.Parse() 43 | if *help { 44 | flag.PrintDefaults() 45 | os.Exit(0) 46 | } 47 | 48 | if *workDir == "" { 49 | exe, err := os.Executable() 50 | maybeDie(err) 51 | rootPath = filepath.Dir(exe) 52 | } else { 53 | rootPath = *workDir 54 | } 55 | 56 | fmt.Println("welcome to tubesync version", version) 57 | fmt.Println(" for", *host) 58 | fmt.Println("library directory:", rootPath) 59 | 60 | var email string 61 | fmt.Print("email (blank to quit): ") 62 | fmt.Scanln(&email) 63 | if email == "" { 64 | fmt.Println("email is blank, bye") 65 | os.Exit(1) 66 | } 67 | 68 | fmt.Print("password (hidden): ") 69 | password, err := term.ReadPassword(int(syscall.Stdin)) 70 | maybeDie(err) 71 | 72 | fmt.Println("\nlogging in as", email, "...") 73 | err = login(email, string(password)) 74 | maybeDie(err) 75 | fmt.Println("login successful") 76 | 77 | fmt.Print("getting track metadata (might take a while)") 78 | tracks, err := getTracks() 79 | fmt.Println() 80 | maybeDie(err) 81 | fmt.Println("got", len(tracks), "tracks") 82 | fmt.Println("syncing...") 83 | 84 | total := len(tracks) 85 | progress := new(int64) 86 | errct := new(int64) 87 | 88 | dlchan := make(chan tube.Track, *parallel) 89 | var wg sync.WaitGroup 90 | for n := 0; n < *parallel; n++ { 91 | wg.Add(1) 92 | go func() { 93 | for track := range dlchan { 94 | var msg string 95 | name := displayName(track) 96 | if err := download(track); err != nil { 97 | if err == ErrSkip { 98 | msg = fmt.Sprint("skipped: ", name, " (already downloaded)") 99 | } else { 100 | msg = fmt.Sprint("download error: ", track.ID, name, " ", err) 101 | atomic.AddInt64(errct, 1) 102 | } 103 | } else { 104 | msg = "downloaded: " + name 105 | } 106 | prog := atomic.AddInt64(progress, 1) 107 | fmt.Printf("[%d/%d] %s\n", prog, total, msg) 108 | } 109 | wg.Done() 110 | }() 111 | } 112 | 113 | for _, track := range tracks { 114 | dlchan <- track 115 | } 116 | close(dlchan) 117 | wg.Wait() 118 | 119 | fmt.Println("done~") 120 | fmt.Printf("got %d error(s)\n", atomic.LoadInt64(errct)) 121 | fmt.Println("\nPress ENTER to exit") 122 | fmt.Scanln() 123 | 124 | os.Exit(0) 125 | } 126 | 127 | func login(email, pass string) error { 128 | jar, err := cookiejar.New(&cookiejar.Options{ 129 | PublicSuffixList: publicsuffix.List, 130 | }) 131 | if err != nil { 132 | return err 133 | } 134 | client = &http.Client{ 135 | Jar: jar, 136 | } 137 | 138 | req := struct { 139 | Email string 140 | Password string 141 | }{ 142 | Email: email, 143 | Password: pass, 144 | } 145 | resp := struct { 146 | Session string 147 | }{} 148 | 149 | return post("/api/v0/login", req, &resp) 150 | } 151 | 152 | func getTracks() (tube.Tracks, error) { 153 | var tracks tube.Tracks 154 | var resp struct { 155 | Tracks tube.Tracks 156 | Next string 157 | } 158 | var err error 159 | for { 160 | err = get("/api/v0/tracks?start="+resp.Next, &resp) 161 | if err != nil { 162 | return nil, err 163 | } 164 | tracks = append(tracks, resp.Tracks...) 165 | if resp.Next == "" { 166 | break 167 | } 168 | fmt.Print(".") 169 | } 170 | return tracks, nil 171 | } 172 | 173 | func download(track tube.Track) error { 174 | href := track.FileURL() 175 | artist := track.Info.AlbumArtist 176 | if artist == "" { 177 | if track.Info.Artist != "" { 178 | artist = track.Info.Artist 179 | } else { 180 | artist = "Unknown Artist" 181 | } 182 | } 183 | album := track.Info.Album 184 | if album == "" { 185 | album = "Unknown Album" 186 | } 187 | title := track.Info.Title 188 | if title == "" { 189 | if track.Filename != "" { 190 | title = track.Filename 191 | } else { 192 | title = "Untitled" 193 | } 194 | } 195 | var num string 196 | if track.Disc != 0 && track.Number != 0 { 197 | num = fmt.Sprintf("%d-%02d ", track.Disc, track.Number) 198 | } else if track.Number != 0 { 199 | num = fmt.Sprintf("%02d ", track.Number) 200 | } 201 | filename := scrub(num + title + track.Ext()) 202 | 203 | dir := filepath.Join(rootPath, scrub(artist), scrub(album)) 204 | os.MkdirAll(dir, os.ModePerm) 205 | fpath := filepath.Join(dir, filename) 206 | 207 | if ok, err := shouldSkip(fpath, track.ID); ok && err == nil { 208 | return ErrSkip 209 | } else if err != nil { 210 | return err 211 | } 212 | 213 | dlURL := track.DL 214 | if dlURL == "" { 215 | dlURL = *host + href 216 | } 217 | resp, err := client.Get(dlURL) 218 | if err != nil { 219 | return err 220 | } 221 | defer resp.Body.Close() 222 | 223 | f, err := os.Create(fpath) 224 | if err != nil { 225 | return err 226 | } 227 | defer f.Close() 228 | if _, err := io.Copy(f, resp.Body); err != nil { 229 | return err 230 | } 231 | return nil 232 | } 233 | 234 | func post(path string, in interface{}, out interface{}) error { 235 | href := *host + path 236 | 237 | var inRdr io.Reader 238 | if in != nil { 239 | inraw, err := json.Marshal(in) 240 | if err != nil { 241 | return err 242 | } 243 | inRdr = bytes.NewReader(inraw) 244 | } 245 | 246 | resp, err := client.Post(href, "application/json", inRdr) 247 | if err != nil { 248 | return err 249 | } 250 | defer resp.Body.Close() 251 | 252 | body, err := io.ReadAll(resp.Body) 253 | if err != nil { 254 | return err 255 | } 256 | 257 | // fmt.Println("got:", string(body)) 258 | 259 | if resp.StatusCode == 500 { 260 | return fmt.Errorf("%s", strings.TrimPrefix(string(body), "Panic! ")) 261 | } 262 | 263 | if out != nil { 264 | if err := json.Unmarshal(body, out); err != nil { 265 | return err 266 | } 267 | } 268 | return nil 269 | } 270 | 271 | func get(path string, out interface{}) error { 272 | href := *host + path 273 | 274 | resp, err := client.Get(href) 275 | if err != nil { 276 | return err 277 | } 278 | defer resp.Body.Close() 279 | 280 | body, err := io.ReadAll(resp.Body) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | // fmt.Println("got:", string(body)) 286 | 287 | if resp.StatusCode == 500 { 288 | return fmt.Errorf("%s", strings.TrimPrefix(string(body), "Panic! ")) 289 | } 290 | 291 | if out != nil { 292 | if err := json.Unmarshal(body, out); err != nil { 293 | return err 294 | } 295 | } 296 | return nil 297 | } 298 | 299 | func shouldSkip(path, hash string) (bool, error) { 300 | if _, err := os.Stat(path); err != nil { 301 | if os.IsNotExist(err) { 302 | return false, nil 303 | } 304 | return false, err 305 | } 306 | f, err := os.Open(path) 307 | if err != nil { 308 | return false, err 309 | } 310 | defer f.Close() 311 | 312 | sum, err := sha1Sum(f) 313 | if err != nil { 314 | return false, err 315 | } 316 | if sum == hash { 317 | return true, nil 318 | } 319 | return false, nil 320 | } 321 | 322 | func sha1Sum(r io.ReadSeeker) (string, error) { 323 | h := sha1.New() 324 | _, err := io.Copy(h, r) 325 | if err != nil { 326 | return "", nil 327 | } 328 | return fmt.Sprintf("%x", h.Sum(nil)), nil 329 | } 330 | 331 | func scrub(name string) string { 332 | name = strings.ReplaceAll(name, "/", "-") 333 | name = strings.ReplaceAll(name, "\\", "-") 334 | name = strings.ReplaceAll(name, "...", "") 335 | name = strings.ReplaceAll(name, "..", "") 336 | const cutset = `<>:"|?*` 337 | for _, chr := range cutset { 338 | name = strings.ReplaceAll(name, string(chr), "") 339 | } 340 | return name 341 | } 342 | 343 | func displayName(track tube.Track) string { 344 | return fmt.Sprintf("%s - %s - %s", track.Info.Artist, track.Info.Album, track.Info.Title) 345 | } 346 | 347 | func maybeDie(err error) { 348 | if err == nil { 349 | return 350 | } 351 | fmt.Println("error:", err.Error()) 352 | os.Exit(1) 353 | } 354 | --------------------------------------------------------------------------------