diff --git a/.env.example b/.env.example index f7d74cd..3c953fc 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ DSN=mydbname.db DB_POOL_SIZE=100 -YT_API_KEY=ALzaSyAWJfmC4-ZSNd0ntT3even_tryMA9B7tBQ -LETSENCRYPT_EMAIL=me@email.com +YT_API_KEYS=ALzaSyAWJfmC4-ZSNd0ntT3even_tryMA9B7tBQ,AIzaSyAUwQwQsherenxjjLTSVtheH0d1vOsam0o +LETSENCRYPT_EMAIL=me@email.com \ No newline at end of file diff --git a/.github/workflows/build-tele.yml b/.github/workflows/build-tele.yml new file mode 100644 index 0000000..b589f36 --- /dev/null +++ b/.github/workflows/build-tele.yml @@ -0,0 +1,56 @@ +name: build image, publish and deploy + +on: + push: + branches: + - master + paths: + - 'cmd/tele/**' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.tele + push: true + tags: ghcr.io/thedmdim/ytstalker/tele:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build + name: deploy image + runs-on: ubuntu-latest + + steps: + - name: prepare ssh + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SERVER_IP: ${{ secrets.SERVER_IP }} + + - name: SSH into the server and update the code + run: | + ssh $SERVER_USERNAME@$SERVER_IP "wget -qO - https://raw.githubusercontent.com/thedmdim/ytstalker/master/docker-compose.yml > /root/ytstalker/docker-compose.yml && docker compose -f /root/ytstalker/docker-compose.yml up --pull always -d tele" + env: + SERVER_USERNAME: ${{ secrets.SERVER_USERNAME }} + SERVER_IP: ${{ secrets.SERVER_IP }} \ No newline at end of file diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-web.yml similarity index 88% rename from .github/workflows/build-and-deploy.yml rename to .github/workflows/build-web.yml index d27dca3..1e25817 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-web.yml @@ -4,11 +4,11 @@ on: push: branches: - master - paths-ignore: - - README.md + paths: + - 'cmd/app/**' jobs: - publish: + build: runs-on: ubuntu-latest permissions: contents: read @@ -27,12 +27,12 @@ jobs: with: context: . push: true - tags: ghcr.io/thedmdim/ytstalker:latest + tags: ghcr.io/thedmdim/ytstalker/app:latest cache-from: type=gha cache-to: type=gha,mode=max deploy: - needs: publish + needs: build name: deploy image runs-on: ubuntu-latest @@ -49,7 +49,7 @@ jobs: - name: SSH into the server and update the code run: | - ssh $SERVER_USERNAME@$SERVER_IP "wget -qO - https://raw.githubusercontent.com/thedmdim/ytstalker/master/docker-compose.yml > /root/ytstalker/docker-compose.yml && docker compose -f /root/ytstalker/docker-compose.yml up --pull always -d ytstalker" + ssh $SERVER_USERNAME@$SERVER_IP "wget -qO - https://raw.githubusercontent.com/thedmdim/ytstalker/master/docker-compose.yml > /root/ytstalker/docker-compose.yml && docker compose -f /root/ytstalker/docker-compose.yml up --pull always -d app" env: SERVER_USERNAME: ${{ secrets.SERVER_USERNAME }} SERVER_IP: ${{ secrets.SERVER_IP }} diff --git a/Dockerfile b/Dockerfile.app similarity index 77% rename from Dockerfile rename to Dockerfile.app index ce7708e..82a6056 100644 --- a/Dockerfile +++ b/Dockerfile.app @@ -2,13 +2,12 @@ FROM golang:1.21 as builder WORKDIR /usr/src/ytstalker COPY go.mod go.sum . RUN go mod download -COPY app app/ -RUN ls -RUN CGO_ENABLED=0 go build -v -o /usr/bin/ytstalker/app ./app +COPY cmd cmd/ +RUN CGO_ENABLED=0 go build -v -o /usr/bin/ytstalker/app ./cmd/app FROM gcr.io/distroless/static-debian11 WORKDIR /usr/bin/ytstalker -COPY --from=builder /usr/bin/ytstalker/app . COPY web /usr/bin/ytstalker/web/ +COPY --from=builder /usr/bin/ytstalker/app . EXPOSE 80 ENTRYPOINT ["/usr/bin/ytstalker/app"] \ No newline at end of file diff --git a/Dockerfile.tele b/Dockerfile.tele new file mode 100644 index 0000000..3c2a2fa --- /dev/null +++ b/Dockerfile.tele @@ -0,0 +1,11 @@ +FROM golang:1.21 as builder +WORKDIR /usr/src/ytstalker +COPY go.mod go.sum . +RUN go mod download +COPY cmd cmd/ +RUN CGO_ENABLED=0 go build -v -o /usr/bin/ytstalker/tele ./cmd/tele + +FROM gcr.io/distroless/static-debian11 +WORKDIR /usr/bin/ytstalker +COPY --from=builder /usr/bin/ytstalker/tele . +ENTRYPOINT ["/usr/bin/ytstalker/tele"] \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 5a32a5e..0000000 --- a/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# ytstalker - -*It's so easy to get lost in youtube...* - -*A lot of wonderful, beautiful or even mysterious videos remain unseen.* - -*Now you can just open [ytstalker.fun](https://ytstalker.fun)* - -# donate - -- [buymeacoffee](https://www.buymeacoffee.com/5bphbcgzw8s) -- bitcoin: `bc1qqkl8ejxrfggs40a29x3ms7eeg9443a44gnykz8` - -# community -[@mov3371](https://t.me/mov3371) - diff --git a/app/conf/conf.go b/app/conf/conf.go deleted file mode 100644 index 8e0557c..0000000 --- a/app/conf/conf.go +++ /dev/null @@ -1,35 +0,0 @@ -package conf - -import ( - "log" - "os" - "strconv" -) - -type Config struct { - YtApiKey string - DSN string - DbPoolSize int - RandomSeed int64 -} - -func ParseConfig() *Config { - config := &Config{} - - config.DSN = os.Getenv("DSN") - if config.DSN == "" { - config.DSN = "server.db" - } - - config.YtApiKey = os.Getenv("YT_API_KEY") - if config.YtApiKey == "" { - log.Fatal("You forgot to provide YouTube API key!") - } - - config.DbPoolSize, _ = strconv.Atoi(os.Getenv("DB_POOL_SIZE")) - if config.DbPoolSize == 0 { - config.DbPoolSize = 100 - } - - return config -} diff --git a/app/handlers/random_hander.go b/cmd/app/handlers/random_hander.go similarity index 88% rename from app/handlers/random_hander.go rename to cmd/app/handlers/random_hander.go index 06f8efb..7ca85f6 100644 --- a/app/handlers/random_hander.go +++ b/cmd/app/handlers/random_hander.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "log" "net/http" @@ -47,6 +48,8 @@ type SearchCriteria struct { Musiconly bool } +var ErrNoVideoFound = errors.New("no video found") + func ParseQueryParams(params url.Values) *SearchCriteria { sc := &SearchCriteria{} @@ -132,11 +135,18 @@ func (sc *SearchCriteria) CheckVideo(video *Video) bool { return true } -func (s *Router) TakeFirstUnseen(conn *sqlite.Conn, sc *SearchCriteria, visitor string) (*Video, error) { +func TakeFirstUnseen(conn *sqlite.Conn, visitor string, sc *SearchCriteria) (*Video, error) { video := &Video{} - stmt, _, err := conn.PrepareTransient(fmt.Sprintf(` + var where string + if sc != nil { + where = sc.MakeWhere() + } + + visitor = strings.ReplaceAll(visitor, " ", "") + + stmt, err := conn.Prepare(fmt.Sprintf(` SELECT id, uploaded, title, views, vertical, category FROM videos WHERE id NOT IN ( @@ -147,7 +157,7 @@ func (s *Router) TakeFirstUnseen(conn *sqlite.Conn, sc *SearchCriteria, visitor ORDER BY random() LIMIT 1`, visitor, - sc.MakeWhere(), + where, )) if err != nil { return nil, fmt.Errorf("error preparing query: %w", err) @@ -157,7 +167,7 @@ func (s *Router) TakeFirstUnseen(conn *sqlite.Conn, sc *SearchCriteria, visitor return nil, err } if !rows { - return nil, nil + return nil, ErrNoVideoFound } video.ID = stmt.GetText("id") @@ -175,7 +185,7 @@ func (s *Router) TakeFirstUnseen(conn *sqlite.Conn, sc *SearchCriteria, visitor return video, nil } -func (s *Router) RememberSeen(conn *sqlite.Conn, visitorId string, videoId string) error { +func RememberSeen(conn *sqlite.Conn, visitorId string, videoId string) error { endFn, err := sqlitex.ImmediateTransaction(conn) if err != nil { @@ -193,8 +203,9 @@ func (s *Router) RememberSeen(conn *sqlite.Conn, visitorId string, videoId strin if _, err = stmt.Step(); err != nil { return err } + stmt.ClearBindings(); stmt.Reset() - stmt, err = conn.Prepare(`INSERT INTO videos_visitors (visitor_id, video_id) VALUES (?, ?);`) + stmt = conn.Prep(`INSERT INTO videos_visitors (visitor_id, video_id) VALUES (?, ?);`) if err != nil { return fmt.Errorf("error preparing query: %w", err) } @@ -203,10 +214,7 @@ func (s *Router) RememberSeen(conn *sqlite.Conn, visitorId string, videoId strin if _, err = stmt.Step(); err != nil { return err } - err = stmt.Reset() - if err != nil { - return err - } + stmt.ClearBindings(); stmt.Reset() return nil } @@ -223,7 +231,7 @@ func (s *Router) GetRandom(w http.ResponseWriter, r *http.Request) { defer s.db.Put(conn) searchCriteria := ParseQueryParams(params) - video, err := s.TakeFirstUnseen(conn, searchCriteria, visitor) + video, err := TakeFirstUnseen(conn, visitor, searchCriteria) if err != nil { log.Println("take first unseen failed:", err.Error()) } @@ -240,7 +248,7 @@ func (s *Router) GetRandom(w http.ResponseWriter, r *http.Request) { response.Reactions = reactions encoder.Encode(response) - err = s.RememberSeen(conn, visitor, video.ID) + err = RememberSeen(conn, visitor, video.ID) if err != nil { log.Println("error remembering seen:", err.Error()) } diff --git a/app/handlers/rating_handler.go b/cmd/app/handlers/rating_handler.go similarity index 100% rename from app/handlers/rating_handler.go rename to cmd/app/handlers/rating_handler.go diff --git a/app/handlers/reactions_handler.go b/cmd/app/handlers/reactions_handler.go similarity index 100% rename from app/handlers/reactions_handler.go rename to cmd/app/handlers/reactions_handler.go diff --git a/app/handlers/router.go b/cmd/app/handlers/router.go similarity index 100% rename from app/handlers/router.go rename to cmd/app/handlers/router.go diff --git a/app/handlers/video_handler.go b/cmd/app/handlers/video_handler.go similarity index 100% rename from app/handlers/video_handler.go rename to cmd/app/handlers/video_handler.go diff --git a/app/main.go b/cmd/app/main.go similarity index 88% rename from app/main.go rename to cmd/app/main.go index 9d008ed..7098537 100644 --- a/app/main.go +++ b/cmd/app/main.go @@ -10,19 +10,21 @@ import ( "syscall" "time" - "ytstalker/app/conf" - "ytstalker/app/handlers" - "ytstalker/app/youtube" + "ytstalker/cmd/app/handlers" + "ytstalker/cmd/app/youtube" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) func main() { - config := conf.ParseConfig() - // prepare db - db, err := sqlitex.NewPool(config.DSN, sqlitex.PoolOptions{PoolSize: config.DbPoolSize}) + dsn := os.Getenv("DSN") + if dsn == "" { + dsn = "server.db" + } + + db, err := sqlitex.NewPool(dsn, sqlitex.PoolOptions{PoolSize: 100}) if err != nil { log.Fatal("cannot open db", err) } @@ -35,7 +37,11 @@ func main() { log.Println("database ready") // init youtube api requester - ytr := youtube.NewYouTubeRequester(config) + ytApiKey := os.Getenv("YT_API_KEY") + if ytApiKey == "" { + log.Fatal("You forgot to provide YouTube API key!") + } + ytr := youtube.NewYouTubeRequester(ytApiKey) // make router handler := handlers.NewRouter(db) diff --git a/app/tables.go b/cmd/app/tables.go similarity index 100% rename from app/tables.go rename to cmd/app/tables.go diff --git a/app/youtube/api.go b/cmd/app/youtube/api.go similarity index 92% rename from app/youtube/api.go rename to cmd/app/youtube/api.go index 805c4d4..70eb305 100644 --- a/app/youtube/api.go +++ b/cmd/app/youtube/api.go @@ -6,20 +6,18 @@ import ( "net/http" "net/url" "strings" - - "ytstalker/app/conf" ) type YouTubeRequester struct { noRedirectClient *http.Client - conf *conf.Config + token string baseUrl string } -func NewYouTubeRequester(conf *conf.Config) *YouTubeRequester { +func NewYouTubeRequester(token string) *YouTubeRequester { return &YouTubeRequester{ baseUrl: "https://www.googleapis.com/youtube/v3", - conf: conf, + token: token, noRedirectClient: &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse diff --git a/app/youtube/models.go b/cmd/app/youtube/models.go similarity index 100% rename from app/youtube/models.go rename to cmd/app/youtube/models.go diff --git a/app/youtube/utils.go b/cmd/app/youtube/utils.go similarity index 98% rename from app/youtube/utils.go rename to cmd/app/youtube/utils.go index 6458635..893d697 100644 --- a/app/youtube/utils.go +++ b/cmd/app/youtube/utils.go @@ -27,7 +27,7 @@ type Video struct { func (y *YouTubeRequester) Request(req *http.Request) (*http.Response, error) { q := req.URL.Query() - q.Add("key", y.conf.YtApiKey) + q.Add("key", y.token) req.URL.RawQuery = q.Encode() res, err := http.DefaultClient.Do(req) if err != nil { diff --git a/cmd/tele/main.go b/cmd/tele/main.go new file mode 100644 index 0000000..b699cf5 --- /dev/null +++ b/cmd/tele/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "ytstalker/cmd/app/handlers" + + "github.com/NicoNex/echotron/v3" + "zombiezen.com/go/sqlite/sqlitex" +) + +type Session struct { + MessageID int + Text string + Year int +} + +const YouTubeFounded = 2006 + +const DefaultYear = 2006 + +func main() { + token := os.Getenv("TG_TOKEN") + if token == "" { + log.Fatal("no telegram token provided") + } + + dsn := os.Getenv("DSN") + if dsn == "" { + log.Fatal("no dsn provided") + } + + db, err := sqlitex.NewPool(dsn, sqlitex.PoolOptions{PoolSize: 100}) + if err != nil { + log.Fatal("cannot open db", err) + } + + api := echotron.NewAPI(token) + for u := range echotron.PollingUpdates(token) { + + visitor := strconv.FormatInt(u.ChatID(), 10) + + if u.Message != nil && u.Message.Text == "/start" { + + conn := db.Get(context.Background()) + sc := &handlers.SearchCriteria{ + YearsFrom: strconv.Itoa(DefaultYear), + YearsTo: strconv.Itoa(DefaultYear), + } + video, err := handlers.TakeFirstUnseen(conn, visitor, sc) + db.Put(conn) + if err != nil { + log.Println("cannot take video:", err) + continue + } + + + text := "Title: " + video.Title + "\n" + + "Views: " + strconv.FormatInt(video.Views, 10) + "\n" + + "Uploaded: " + time.Unix(video.UploadedAt, 0).Format("02.01.2006") + "\n" + + "\n" + + "https://www.youtube.com/watch?v=" + video.ID + + + markup := GetKeyboard(DefaultYear) + _, err = api.SendMessage(text, u.ChatID(), &echotron.MessageOptions{ReplyMarkup: markup}) + if err != nil { + fmt.Println("cannot send message:", err) + continue + } + + err = handlers.RememberSeen(conn, visitor, video.ID) + if err != nil { + log.Println("cannot remember seen: ", err) + } + } + + if u.CallbackQuery != nil && strings.HasPrefix(u.CallbackQuery.Data, "/random") { + + year, err := strconv.Atoi(strings.Replace(u.CallbackQuery.Data, "/random", "", 1)) + if err != nil { + log.Println("cannot parse year:", err) + continue + } + + conn := db.Get(context.Background()) + sc := &handlers.SearchCriteria{ + YearsFrom: strconv.Itoa(year), + YearsTo: strconv.Itoa(year), + } + video, err := handlers.TakeFirstUnseen(conn, visitor, sc) + db.Put(conn) + if err != nil { + log.Println("cannot take video:", err) + continue + } + + kb := u.CallbackQuery.Message.ReplyMarkup + + prevMsg := echotron.NewMessageID(u.ChatID(), u.CallbackQuery.Message.ID) + _, err = api.EditMessageText(u.CallbackQuery.Message.Text, prevMsg, nil) + if err != nil { + log.Println("cannot remove keyboard from prev message:", err) + } + + text := "Title: " + video.Title + "\n" + + "Views: " + strconv.FormatInt(video.Views, 10) + "\n" + + "Uploaded: " + time.Unix(video.UploadedAt, 0).Format("02.01.2006") + "\n" + + "\n" + + "https://www.youtube.com/watch?v=" + video.ID + + _, err = api.SendMessage(text, u.ChatID(), &echotron.MessageOptions{ReplyMarkup: kb}) + if err != nil { + fmt.Println("cannot send message:", err) + continue + } + + err = handlers.RememberSeen(conn, visitor, video.ID) + if err != nil { + log.Println("cannot remember seen: ", err) + } + + } + + + if u.CallbackQuery != nil && strings.HasPrefix(u.CallbackQuery.Data, "/set") { + + year, err := strconv.Atoi(strings.Replace(u.CallbackQuery.Data, "/set", "", 1)) + if err != nil { + log.Println("cannot parse year:", err) + continue + } + + kb := GetKeyboard(year) + _, err = api.EditMessageText( + u.CallbackQuery.Message.Text, + echotron.NewMessageID(u.ChatID(), u.CallbackQuery.Message.ID), + &echotron.MessageTextOptions{ReplyMarkup: kb}, + ) + + if err != nil { + fmt.Println("cannot set year:", err) + continue + } + + } + + } +} + +var ErrNoSession = errors.New("no session found") + + +func GetKeyboard(year int) echotron.InlineKeyboardMarkup { + + firstRow := make([]echotron.InlineKeyboardButton, 0, 3) + if year <= YouTubeFounded { + firstRow = append(firstRow, echotron.InlineKeyboardButton{Text: "X", CallbackData: "/noaction"}) + } else { + firstRow = append(firstRow, echotron.InlineKeyboardButton{Text: "<-", CallbackData: "/set" + strconv.Itoa(year-1)}) + } + + + firstRow = append(firstRow, echotron.InlineKeyboardButton{Text: strconv.Itoa(year), CallbackData: "/noaction" + strconv.Itoa(year)}) + + if year >= time.Now().Year() { + firstRow = append(firstRow, echotron.InlineKeyboardButton{Text: "X", CallbackData: "/noaction"}) + } else { + firstRow = append(firstRow, echotron.InlineKeyboardButton{Text: "->", CallbackData: "/set" + strconv.Itoa(year+1)}) + } + + secondRow := []echotron.InlineKeyboardButton{{Text: "Random", CallbackData: "/random" + strconv.Itoa(year)}} + + return echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{firstRow, secondRow}} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e7214ad..4223f39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,20 @@ networks: driver: bridge services: - ytstalker: - container_name: ytstalker - image: ghcr.io/thedmdim/ytstalker + + tele: + container_name: tele + image: ghcr.io/thedmdim/ytstalker/tele + restart: always + volumes: + - .:/usr/bin/ytstalker/db + environment: + DSN: db/${DSN} + TG_TOKEN: ${TG_TOKEN} + + app: + container_name: app + image: ghcr.io/thedmdim/ytstalker/app restart: always volumes: - .:/usr/bin/ytstalker/db diff --git a/go.mod b/go.mod index 26d19b0..699aafd 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/NicoNex/echotron/v3 v3.30.0 github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/mattn/go-isatty v0.0.16 // indirect diff --git a/go.sum b/go.sum index 9969418..81c8b3d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/NicoNex/echotron/v3 v3.30.0 h1:5D+hGMjLIjSvqZ6AllHoUzRCVOL5K/HaIzByYwDXRIE= +github.com/NicoNex/echotron/v3 v3.30.0/go.mod h1:LpP5IyHw0y+DZUZMBgXEDAF9O8feXrQu7w7nlJzzoZI= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= diff --git a/web/partials/footer.html b/web/partials/footer.html index d09d186..dc3d9e7 100644 --- a/web/partials/footer.html +++ b/web/partials/footer.html @@ -1,10 +1,6 @@ {{ define "footer" }} {{ end }} \ No newline at end of file