├── 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 |
"
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 |
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 |
8 | {{else}}
9 |
10 | {{end}}
11 |
12 |
13 |
14 | {{range $album}}
15 |
21 | {{.Info.Title}}
22 | {{.Info.Artist}}
23 | {{.Info.Album}}
24 |
25 | {{if .Picture.ID}}
26 |
27 | {{end}}
28 |
29 |
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 |
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 |
51 | {{tr "intro_1"}}
52 | {{tr "intro_2"}}
53 | {{tr "intro_3"}}
54 | {{tr "intro_4"}}
55 | {{tr "intro_4-1"}}
56 | {{tr "intro_4-2"}}
57 | {{tr "intro_5"}}
58 | {{tr "intro_6"}}
59 | {{tr "intro_7"}}
60 |
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 |
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 |
16 | We only store personal information (e-mail, IP address) strictly necessary for facilitating our service and protecting your account's security.
17 | We will keep your personal information safe.
18 | We partner with Stripe to process your payment information. This is securely handled by Stripe and not stored here.
19 | We don't use third-party analytics.
20 | We will never share your personal information with third-parties.
21 | We will never provide your listening data to third-parties, unless you opt-in to scrobbling data (not yet implemented).
22 |
23 |
24 | Cookies
25 |
26 |
27 | We only use cookies strictly necessary for providing service.
28 |
29 | We don't set any analytics, tracking, or advertising cookies.
30 | The purchase page may use a cookie from Stripe to prevent fraud.
31 |
32 |
33 | E-mail
34 |
35 |
36 | We only use your e-mail to provide your account and inform you of important service messages.
37 | We don't use your e-mail for marketing or advertising. You may opt-in to an optional newsletter to receive updates about the site.
38 | We provide your e-mail to Stripe to allow you to pay for the service and receive important messages regarding your payments and subscription.
39 |
40 |
41 | Security
42 |
43 |
44 | We store passwords with a secure hash function.
45 | We don't store your financial information.
46 | All communications go through HTTPS.
47 | In the event of a data breach, affected users will be notified via e-mail.
48 |
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 |
--------------------------------------------------------------------------------
/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 |
17 | These terms may change.
18 | You have to agree to the terms of service to use the site.
19 | You must be a member to access the site.
20 | We can't guarantee 100% uptime. In the event of significant downtime (1 day or more), paid users will receive credit for the time the site was inaccessible.
21 |
22 | Payment
23 |
24 | If you don't pay for the service, you may lose access to your files until you pay.
25 | You can cancel your subscription at any time.
26 | The storage quota provided by plans may be changed. You will be notified on the site.
27 | Accounts registered after Early Access include a free 14-day trial. After this trial, you must subscribe to access the site. You can cancel your trial at any time. Unless you sign up for a paid plan, you won't be charged.
28 | If you are unsatisfied with the product, you may request a refund during your first month of service.
29 | If you exceed your storage quota, you may temporarily lose access to some of your files until you upgrade your plan or delete excess files.
30 | We will keep your files for at least 2 months after you stop paying, but after that they may be deleted. Ideally, we will keep them as long as possible.
31 |
32 | Ownership
33 |
34 | You may not upload files you don't have the rights to.
35 | You may not upload anything that violates the law.
36 | Users are completely responsible for their content. We don't claim ownership of any uploaded content. We only use the files in order to provide services to the users that uploaded them.
37 | You may not abuse the service. This includes denial of service, hacking, accessing other people's accounts or files, etc. However, if you responsibly disclose vulnerabilities we won't punish you; you'll receive some free credits as appreciation for your cooperation.
38 | You may not share your account if it violates the law with regards to the music you upload.
39 |
40 | Environment
41 |
42 | The site requires JavaScript. It works in the latest version of Chrome, Firefox, Safari. Some browsers might not support every feature.
43 | We can't guarantee support for any device, operating system, program, etc. You are responsible for using something that works. That being said, we'll do our best to support devices within reason.
44 |
45 |
46 | Support
47 |
48 |
49 | Support is provided by e-mail. We will try to accomodate questions as soon as possible.
50 | Address: greg.roseberry@gmail.com
51 |
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 | inter.tube(インターチューブ) https://inter.tube
69 |
70 |
71 | 所在地
72 | 東京都港区 ※詳細については問い合わせください
73 |
74 |
75 | 商品の名称
76 | サービス利用料課金
77 |
78 |
79 | 販売価格
80 | 別途ページにて記載しています
81 |
82 |
83 | 連絡先
84 | greg.roseberry@gmail.com
85 |
86 |
87 | お支払方法
88 | クレジットカード
89 |
90 |
91 | 引渡し時期
92 | 即時
93 |
94 |
95 | 返金
96 | アカウント登録日から一ヶ月以内であれば返金に応じます。問い合わせください。
97 |
98 |
99 | コンテンツの閲覧保証ブラウザ
100 | Chrome / Firefox / Safariの各最新版
101 |
102 |
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 | {{tr "noscript"}}
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 |
92 |
93 | {{tr "index_more"}}
94 |
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 |
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(`%s `, 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(`%s `, 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 |
111 | {{tr .Kind.Msg}}
112 |
113 | {{end}}
114 |
115 |
116 | {{range $.Plans}}
117 |
118 | {{.Quota | bytesize}}
119 |
120 | {{end}}
121 |
122 |
123 | {{range $.Plans}}
124 | {{$price := index $.Prices .Kind}}
125 |
126 | {{currency $price.UnitAmount $price.Currency}}
127 | {{tr "buy_monthly"}}
128 |
129 | {{end}}
130 |
131 |
132 | {{range $.Plans}}
133 | {{$price := index $.Prices .Kind}}
134 |
135 | {{tr "buy_subscribe"}}
136 |
137 | {{end}}
138 |
139 |
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 |
54 | {{tr "subsonic_basic"}}
55 |
56 | {{tr "subsonic_auth"}}
57 | {{tr "subsonic_support"}}
58 |
59 |
60 |
61 | iOS
62 |
63 | play:Sub
64 |
65 |
66 |
67 | Android
68 |
69 | Subtracks
70 |
71 |
72 |
73 | Others
74 |
75 |
76 |
77 | basic settings
78 |
79 |
80 |
81 | {{tr "subsonic_settings"}}
82 |
83 | {{tr "serveraddr"}}
84 | https://inter.tube
85 |
86 |
87 | {{tr "username"}}
88 |
89 | {{with $.User.Email}}
90 | {{.}}
91 | {{else}}
92 | (your e-mail address)
93 | {{end}}
94 |
95 |
96 |
97 | {{tr "password"}}
98 |
99 | (your inter.tube password)
100 |
101 |
102 |
103 | {{tr "subsonic_authsetting"}}
104 |
105 | legacy mode, "force plain-text password", etc.
106 | (only required/exists for certain apps, see below)
107 |
108 |
109 |
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 | Tap the "play:Sub" menu icon on the bottom right.
146 | Tap the name of the server in the first menu item
147 | Tap "Selected server"
148 | Tap "Add server"
149 | Fill in the server address, server name, username, password as below
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 | Tap the Settings icon on the bottom right
164 | Tap Add server
165 | Fill in the server address, username, password as below
166 | Enable "force plain text password"
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 |
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 |
--------------------------------------------------------------------------------