This commit is contained in:
stefanodvx 2025-04-14 13:05:43 +02:00
parent 264c97183e
commit 3faede7b1c
74 changed files with 6228 additions and 1 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
BOT_API_URL=https://api.telegram.org
BOT_TOKEN=12345678:ABC-DEF1234ghIkl-zyx57W2P0s
DB_HOST=localhost
DB_PORT=3306
DB_NAME=govd
DB_USER=govd
DB_PASSWORD=password
REPO_URL=https://github.com/govdbot/govd

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
*.exe
*.json
*.txt
*.py
*.html
old/
.env
.idea/
downloads
govd
.DS_Store

View file

@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# govd
a telegram bot for downloading media from various platforms
this project was born after the discontinuation of a highly popular bot known as UVD, and draws significant inspiration from [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- official instance: [@govd_bot](https://t.me/govd_bot)
- support group: [govdsupport](https://t.me/govdsupport)
## features
- download media from various platforms
- download videos, photos, and audio
- inline mode support
- group chat support with customizable settings
- media caption support
## dependencies
- ffmpeg >= 6.1.1
- libheif >= 1.19.7
- pkg-config
- mysql db
## botapi
to avoid limits on files, you should host your own telegram botapi. public bot instance is currently running under a botapi fork, [tdlight-telegram-bot-api](https://github.com/tdlight-team/tdlight-telegram-bot-api)
## installation
```bash
git clone https://github.com/govdbot/govd.git
cd govd
# edit .env file with your bot token and database credentials
sh build.sh
```
## cookies
some extractors require cookies for download. to add your cookies, just insert a txt file in cookies folder (netscape format)
## todo
- [ ] add more extractors
- [ ] switch to sonic json parser
- [ ] switch to native libav
- [ ] add tests
- [ ] add dockerfile and compose
- [ ] improve error handling
- [ ] add support for telegram wehbhooks
- [ ] switch to pgsql (?)
- [ ] better API (?)

134
bot/core/default.go Normal file
View file

@ -0,0 +1,134 @@
package core
import (
"fmt"
"govd/database"
"govd/models"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func HandleDefaultFormatDownload(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
storedMedias, err := database.GetDefaultMedias(
dlCtx.Extractor.CodeName,
dlCtx.MatchedContentID,
)
if err != nil {
return fmt.Errorf("failed to get default medias: %w", err)
}
if len(storedMedias) > 0 {
return HandleDefaultStoredFormatDownload(
bot, ctx, dlCtx, storedMedias,
)
}
response, err := dlCtx.Extractor.Run(dlCtx)
if err != nil {
return fmt.Errorf("extractor fetch run failed: %w", err)
}
mediaList := response.MediaList
if len(mediaList) == 0 {
return fmt.Errorf("no media found for content ID: %s", dlCtx.MatchedContentID)
}
for i := range mediaList {
defaultFormat := mediaList[i].GetDefaultFormat()
if defaultFormat == nil {
return fmt.Errorf("no default format found for media at index %d", i)
}
if len(defaultFormat.URL) == 0 {
return fmt.Errorf("media format at index %d has no URL", i)
}
// ensure we can merge video and audio formats
ensureMergeFormats(mediaList[i], defaultFormat)
mediaList[i].Format = defaultFormat
}
medias, err := DownloadMedias(mediaList, nil)
if err != nil {
return fmt.Errorf("failed to download media list: %w", err)
}
if len(medias) == 0 {
return fmt.Errorf("no formats downloaded")
}
isCaptionEnabled := true
if dlCtx.GroupSettings != nil && !*dlCtx.GroupSettings.Captions {
isCaptionEnabled = false
}
messageCaption := FormatCaption(
mediaList[0],
isCaptionEnabled,
)
// plugins act as post-processing for the media.
// they are run after the media is downloaded
// and before it is sent to the user
// this allows for things like merging audio and video, etc.
for _, media := range medias {
for _, plugin := range media.Media.Format.Plugins {
err = plugin(media)
if err != nil {
return fmt.Errorf("failed to run plugin: %w", err)
}
}
}
_, err = SendMedias(
bot, ctx, dlCtx,
medias,
&models.SendMediaFormatsOptions{
Caption: messageCaption,
IsStored: false,
},
)
if err != nil {
return fmt.Errorf("failed to send formats: %w", err)
}
return nil
}
func HandleDefaultStoredFormatDownload(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
storedMedias []*models.Media,
) error {
isCaptionEnabled := true
if dlCtx.GroupSettings != nil && !*dlCtx.GroupSettings.Captions {
isCaptionEnabled = false
}
messageCaption := FormatCaption(
storedMedias[0],
isCaptionEnabled,
)
var formats []*models.DownloadedMedia
for _, media := range storedMedias {
formats = append(formats, &models.DownloadedMedia{
FilePath: "",
ThumbnailFilePath: "",
Media: media,
})
}
_, err := SendMedias(
bot, ctx, dlCtx,
formats,
&models.SendMediaFormatsOptions{
Caption: messageCaption,
IsStored: true,
},
)
if err != nil {
return fmt.Errorf("failed to send media: %w", err)
}
return nil
}

187
bot/core/download.go Normal file
View file

@ -0,0 +1,187 @@
package core
import (
"context"
"fmt"
"path/filepath"
"sort"
"sync"
"govd/enums"
"govd/models"
"govd/util"
)
func downloadMediaItem(
ctx context.Context,
media *models.Media,
config *models.DownloadConfig,
idx int,
) (*models.DownloadedMedia, error) {
if config == nil {
config = util.DefaultConfig()
}
format := media.Format
if format == nil {
return nil, fmt.Errorf("media format is nil")
}
fileName := format.GetFileName()
var filePath string
var thumbnailFilePath string
if format.Type != enums.MediaTypePhoto {
if len(format.Segments) == 0 {
path, err := util.DownloadFile(
ctx, format.URL,
fileName, config,
)
if err != nil {
return nil, fmt.Errorf("failed to download file: %w", err)
}
filePath = path
} else {
path, err := util.DownloadFileWithSegments(
ctx, format.Segments,
fileName, config,
)
if err != nil {
return nil, fmt.Errorf("failed to download segments: %w", err)
}
filePath = path
}
if format.Type == enums.MediaTypeVideo || format.Type == enums.MediaTypeAudio {
path, err := getFileThumbnail(format, filePath)
if err != nil {
return nil, fmt.Errorf("failed to get thumbnail: %w", err)
}
thumbnailFilePath = path
}
if format.Type == enums.MediaTypeVideo {
if format.Width == 0 || format.Height == 0 || format.Duration == 0 {
insertVideoInfo(format, filePath)
}
}
} else {
file, err := util.DownloadFileInMemory(ctx, format.URL, config)
if err != nil {
return nil, fmt.Errorf("failed to download image: %w", err)
}
path := filepath.Join(config.DownloadDir, fileName)
if err := util.ImgToJPEG(file, path); err != nil {
return nil, fmt.Errorf("failed to convert image: %w", err)
}
filePath = path
}
return &models.DownloadedMedia{
FilePath: filePath,
ThumbnailFilePath: thumbnailFilePath,
Media: media,
Index: idx,
}, nil
}
func StartDownloadTask(
media *models.Media,
idx int,
config *models.DownloadConfig,
) (*models.DownloadedMedia, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return downloadMediaItem(ctx, media, config, idx)
}
func StartConcurrentDownload(
media *models.Media,
resultsChan chan<- models.DownloadedMedia,
config *models.DownloadConfig,
errChan chan<- error,
wg *sync.WaitGroup,
idx int,
) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result, err := downloadMediaItem(ctx, media, config, idx)
if err != nil {
errChan <- err
return
}
resultsChan <- *result
}
func DownloadMedia(
media *models.Media,
config *models.DownloadConfig,
) (*models.DownloadedMedia, error) {
return StartDownloadTask(media, 0, config)
}
func DownloadMedias(
medias []*models.Media,
config *models.DownloadConfig,
) ([]*models.DownloadedMedia, error) {
if len(medias) == 0 {
return []*models.DownloadedMedia{}, nil
}
if len(medias) == 1 {
result, err := DownloadMedia(medias[0], config)
if err != nil {
return nil, err
}
return []*models.DownloadedMedia{result}, nil
}
resultsChan := make(chan models.DownloadedMedia, len(medias))
errChan := make(chan error, len(medias))
var wg sync.WaitGroup
for idx, media := range medias {
wg.Add(1)
go StartConcurrentDownload(media, resultsChan, config, errChan, &wg, idx)
}
go func() {
wg.Wait()
close(resultsChan)
close(errChan)
}()
var results []*models.DownloadedMedia
var firstError error
select {
case err := <-errChan:
if err != nil {
firstError = err
}
default:
// no errors (yet)
}
for result := range resultsChan {
resultCopy := result // create a copy to avoid pointer issues
results = append(results, &resultCopy)
}
if firstError != nil {
return results, firstError
}
if len(results) > 1 {
sort.SliceStable(results, func(i, j int) bool {
return results[i].Index < results[j].Index
})
}
return results, nil
}

251
bot/core/inline.go Normal file
View file

@ -0,0 +1,251 @@
package core
import (
"fmt"
"log"
"github.com/google/uuid"
"github.com/pkg/errors"
"govd/database"
"govd/enums"
"govd/models"
"govd/util"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var InlineTasks = make(map[string]*models.DownloadContext)
func HandleInline(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
if dlCtx.Extractor.Type != enums.ExtractorTypeSingle {
return util.ErrNotImplemented
}
contentID := dlCtx.MatchedContentID
cached, err := database.GetDefaultMedias(
dlCtx.Extractor.CodeName,
contentID,
)
if err != nil {
return err
}
if len(cached) > 0 {
if len(cached) > 1 {
return util.ErrInlineMediaGroup
}
err = HandleInlineCached(
bot, ctx,
dlCtx, cached[0],
)
if err != nil {
return err
}
return nil
}
err = StartInlineTask(bot, ctx, dlCtx)
if err != nil {
return err
}
return nil
}
func HandleInlineCached(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
media *models.Media,
) error {
var result gotgbot.InlineQueryResult
format := media.Format
resultID := fmt.Sprintf("%d:%s", ctx.EffectiveUser.Id, format.FormatID)
resultTitle := "share"
mediaCaption := FormatCaption(media, true)
_, inputFileType := format.GetFormatInfo()
switch inputFileType {
case "photo":
result = &gotgbot.InlineQueryResultCachedPhoto{
Id: resultID,
PhotoFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "video":
result = &gotgbot.InlineQueryResultCachedVideo{
Id: resultID,
VideoFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "audio":
result = &gotgbot.InlineQueryResultCachedAudio{
Id: resultID,
AudioFileId: format.FileID,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "document":
result = &gotgbot.InlineQueryResultCachedDocument{
Id: resultID,
DocumentFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
default:
return errors.New("unsupported input file type")
}
ctx.InlineQuery.Answer(
bot, []gotgbot.InlineQueryResult{result},
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
},
)
return nil
}
func HandleInlineCachedResult(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
media *models.Media,
) error {
format := media.Format
messageCaption := FormatCaption(media, true)
inputMedia, err := format.GetInputMediaWithFileID(messageCaption)
if err != nil {
return err
}
_, _, err = bot.EditMessageMedia(
inputMedia,
&gotgbot.EditMessageMediaOpts{
InlineMessageId: ctx.ChosenInlineResult.InlineMessageId,
},
)
if err != nil {
return err
}
return nil
}
func StartInlineTask(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
randomID, err := uuid.NewUUID()
if err != nil {
return errors.New("could not generate task ID")
}
taskID := randomID.String()
inlineResult := &gotgbot.InlineQueryResultArticle{
Id: taskID,
Title: "share",
InputMessageContent: &gotgbot.InputTextMessageContent{
MessageText: "loading media plese wait...",
ParseMode: "HTML",
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
},
ReplyMarkup: &gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "...",
CallbackData: "inline:loading",
},
},
},
},
}
ok, err := ctx.InlineQuery.Answer(
bot, []gotgbot.InlineQueryResult{inlineResult},
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
},
)
if err != nil {
log.Println("failed to answer inline query:", err)
}
if !ok {
log.Println("failed to answer inline query")
return nil
}
InlineTasks[taskID] = dlCtx
return nil
}
func GetInlineFormat(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
mediaChan chan<- *models.Media,
errChan chan<- error,
) {
response, err := dlCtx.Extractor.Run(dlCtx)
if err != nil {
errChan <- fmt.Errorf("failed to get media: %w", err)
return
}
mediaList := response.MediaList
if len(mediaList) == 0 {
errChan <- fmt.Errorf("no media found for content ID: %s", dlCtx.MatchedContentID)
}
if len(mediaList) > 1 {
errChan <- util.ErrInlineMediaGroup
return
}
for i := range mediaList {
defaultFormat := mediaList[i].GetDefaultFormat()
if defaultFormat == nil {
errChan <- fmt.Errorf("no default format found for media at index %d", i)
return
}
if len(defaultFormat.URL) == 0 {
errChan <- fmt.Errorf("media format at index %d has no URL", i)
return
}
// ensure we can merge video and audio formats
ensureMergeFormats(mediaList[i], defaultFormat)
mediaList[i].Format = defaultFormat
}
messageCaption := FormatCaption(mediaList[0], true)
medias, err := DownloadMedias(mediaList, nil)
if err != nil {
errChan <- fmt.Errorf("failed to download medias: %w", err)
return
}
msgs, err := SendMedias(
bot, ctx, dlCtx,
medias, &models.SendMediaFormatsOptions{
Caption: messageCaption,
},
)
if err != nil {
errChan <- fmt.Errorf("failed to send media: %w", err)
return
}
msg := &msgs[0]
msg.Delete(bot, nil)
err = StoreMedias(
dlCtx, msgs,
medias,
)
if err != nil {
errChan <- fmt.Errorf("failed to store media: %w", err)
return
}
mediaChan <- medias[0].Media
}

147
bot/core/main.go Normal file
View file

@ -0,0 +1,147 @@
package core
import (
"fmt"
"os"
"slices"
"time"
"github.com/pkg/errors"
"govd/enums"
"govd/models"
"govd/util"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func HandleDownloadRequest(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
chatID := ctx.EffectiveMessage.Chat.Id
if dlCtx.Extractor.Type == enums.ExtractorTypeSingle {
TypingEffect(bot, ctx, chatID)
err := HandleDefaultFormatDownload(bot, ctx, dlCtx)
if err != nil {
return err
}
return nil
}
return util.ErrUnsupportedExtractorType
}
func SendMedias(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
medias []*models.DownloadedMedia,
options *models.SendMediaFormatsOptions,
) ([]gotgbot.Message, error) {
var chatID int64
var messageOptions *gotgbot.SendMediaGroupOpts
if dlCtx.GroupSettings != nil {
if len(medias) > dlCtx.GroupSettings.MediaGroupLimit {
return nil, util.ErrMediaGroupLimitExceeded
}
if !*dlCtx.GroupSettings.NSFW {
for _, media := range medias {
if media.Media.NSFW {
return nil, util.ErrNSFWNotAllowed
}
}
}
}
switch {
case ctx.Message != nil:
chatID = ctx.EffectiveMessage.Chat.Id
messageOptions = &gotgbot.SendMediaGroupOpts{
ReplyParameters: &gotgbot.ReplyParameters{
MessageId: ctx.EffectiveMessage.MessageId,
},
}
case ctx.CallbackQuery != nil:
chatID = ctx.CallbackQuery.Message.GetChat().Id
messageOptions = nil
case ctx.InlineQuery != nil:
chatID = ctx.InlineQuery.From.Id
messageOptions = nil
case ctx.ChosenInlineResult != nil:
chatID = ctx.ChosenInlineResult.From.Id
messageOptions = &gotgbot.SendMediaGroupOpts{
DisableNotification: true,
}
default:
return nil, errors.New("failed to get chat id")
}
var sentMessages []gotgbot.Message
mediaGroupChunks := slices.Collect(
slices.Chunk(medias, 10),
)
for _, chunk := range mediaGroupChunks {
var inputMediaList []gotgbot.InputMedia
for idx, media := range chunk {
var caption string
if idx == 0 {
caption = options.Caption
}
inputMedia, err := media.Media.Format.GetInputMedia(
media.FilePath,
media.ThumbnailFilePath,
caption,
)
if err != nil {
return nil, fmt.Errorf("failed to get input media: %w", err)
}
inputMediaList = append(inputMediaList, inputMedia)
}
mediaType := chunk[0].Media.Format.Type
SendingEffect(bot, ctx, chatID, mediaType)
msgs, err := bot.SendMediaGroup(
chatID,
inputMediaList,
messageOptions,
)
if err != nil {
return nil, err
}
for _, media := range chunk {
if media.FilePath != "" {
os.Remove(media.FilePath)
}
if media.ThumbnailFilePath != "" {
os.Remove(media.ThumbnailFilePath)
}
}
sentMessages = append(sentMessages, msgs...)
if sentMessages[0].Chat.Type != "private" {
if len(mediaGroupChunks) > 1 {
time.Sleep(3 * time.Second)
} // avoid floodwait?
}
}
if len(sentMessages) == 0 {
return nil, errors.New("no messages sent")
}
if !options.IsStored {
err := StoreMedias(
dlCtx,
sentMessages,
medias,
)
if err != nil {
return nil, fmt.Errorf("failed to cache formats: %w", err)
}
}
return sentMessages, nil
}

280
bot/core/util.go Normal file
View file

@ -0,0 +1,280 @@
package core
import (
"context"
"fmt"
"log"
"path/filepath"
"strings"
"github.com/pkg/errors"
"govd/database"
"govd/enums"
"govd/models"
"govd/plugins"
"govd/util"
"govd/util/av"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func getFileThumbnail(
format *models.MediaFormat,
filePath string,
) (string, error) {
fileDir := filepath.Dir(filePath)
fileName := filepath.Base(filePath)
fileExt := filepath.Ext(fileName)
fileBaseName := fileName[:len(fileName)-len(fileExt)]
thumbnailFilePath := filepath.Join(fileDir, fileBaseName+".thumb.jpeg")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if len(format.Thumbnail) > 0 {
file, err := util.DownloadFileInMemory(ctx, format.Thumbnail, nil)
if err != nil {
return "", fmt.Errorf("failed to download file in memory: %w", err)
}
err = util.ImgToJPEG(file, thumbnailFilePath)
if err != nil {
return "", fmt.Errorf("failed to convert to JPEG: %w", err)
}
return thumbnailFilePath, nil
}
if format.Type == enums.MediaTypeVideo {
err := av.ExtractVideoThumbnail(filePath, thumbnailFilePath)
if err != nil {
return "", fmt.Errorf("failed to extract video thumbnail: %w", err)
}
return thumbnailFilePath, nil
}
return "", nil
}
func insertVideoInfo(
format *models.MediaFormat,
filePath string,
) {
width, height, duration := av.GetVideoInfo(filePath)
format.Width = width
format.Height = height
format.Duration = duration
}
func GetMessageFileID(msg *gotgbot.Message) string {
switch {
case msg.Video != nil:
return msg.Video.FileId
case msg.Animation != nil:
return msg.Animation.FileId
case msg.Photo != nil:
return msg.Photo[len(msg.Photo)-1].FileId
case msg.Document != nil:
return msg.Document.FileId
case msg.Audio != nil:
return msg.Audio.FileId
case msg.Voice != nil:
return msg.Voice.FileId
default:
return ""
}
}
func GetMessageFileSize(msg *gotgbot.Message) int64 {
switch {
case msg.Video != nil:
return msg.Video.FileSize
case msg.Animation != nil:
return msg.Animation.FileSize
case msg.Photo != nil:
return msg.Photo[len(msg.Photo)-1].FileSize
case msg.Document != nil:
return msg.Document.FileSize
case msg.Audio != nil:
return msg.Audio.FileSize
case msg.Voice != nil:
return msg.Voice.FileSize
default:
return 0
}
}
func StoreMedias(
dlCtx *models.DownloadContext,
msgs []gotgbot.Message,
medias []*models.DownloadedMedia,
) error {
var storedMedias []*models.Media
if len(medias) == 0 {
return fmt.Errorf("no media to store")
}
for idx, msg := range msgs {
fileID := GetMessageFileID(&msg)
if len(fileID) == 0 {
return fmt.Errorf("no file ID found for media at index %d", idx)
}
fileSize := GetMessageFileSize(&msg)
medias[idx].Media.Format.FileID = fileID
medias[idx].Media.Format.FileSize = fileSize
storedMedias = append(
storedMedias,
medias[idx].Media,
)
}
for _, media := range storedMedias {
err := database.StoreMedia(
dlCtx.Extractor.CodeName,
media.ContentID,
media,
)
if err != nil {
return fmt.Errorf("failed to store media: %w", err)
}
}
return nil
}
func FormatCaption(
media *models.Media,
isEnabled bool,
) string {
newCaption := fmt.Sprintf(
"<a href='%s'>source</a> - @govd_bot\n",
media.ContentURL,
)
if isEnabled && media.Caption.Valid {
text := media.Caption.String
if len(text) > 600 {
text = text[:600] + "..."
}
newCaption += fmt.Sprintf(
"<blockquote expandable>%s</blockquote>\n",
util.EscapeCaption(text),
)
}
return newCaption
}
func TypingEffect(
bot *gotgbot.Bot,
ctx *ext.Context,
chatID int64,
) {
bot.SendChatAction(
chatID,
"typing",
nil,
)
}
func SendingEffect(
bot *gotgbot.Bot,
ctx *ext.Context,
chatID int64,
mediaType enums.MediaType,
) {
action := "upload_document"
if mediaType == enums.MediaTypeVideo {
action = "upload_video"
}
if mediaType == enums.MediaTypeAudio {
action = "upload_audio"
}
if mediaType == enums.MediaTypePhoto {
action = "upload_photo"
}
bot.SendChatAction(
chatID,
action,
nil,
)
}
func HandleErrorMessage(
bot *gotgbot.Bot,
ctx *ext.Context,
err error,
) {
currentError := err
for currentError != nil {
var botError *util.Error
if errors.As(currentError, &botError) {
SendErrorMessage(bot, ctx, fmt.Sprintf(
"error occurred when downloading: %s",
currentError.Error(),
))
return
}
currentError = errors.Unwrap(currentError)
}
lastError := util.GetLastError(err)
errorMessage := fmt.Sprintf(
"error occurred when downloading: %s",
lastError.Error(),
)
if strings.Contains(errorMessage, bot.Token) {
errorMessage = "telegram related error, probably connection issue"
}
SendErrorMessage(bot, ctx, errorMessage)
}
func SendErrorMessage(
bot *gotgbot.Bot,
ctx *ext.Context,
errorMessage string,
) {
log.Println(errorMessage)
switch {
case ctx.Update.Message != nil:
ctx.EffectiveMessage.Reply(
bot,
errorMessage,
nil,
)
case ctx.Update.InlineQuery != nil:
ctx.InlineQuery.Answer(
bot,
nil,
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
Button: &gotgbot.InlineQueryResultsButton{
Text: errorMessage,
StartParameter: "start",
},
},
)
case ctx.ChosenInlineResult != nil:
bot.EditMessageText(
errorMessage,
&gotgbot.EditMessageTextOpts{
InlineMessageId: ctx.ChosenInlineResult.InlineMessageId,
})
}
}
func ensureMergeFormats(
media *models.Media,
videoFormat *models.MediaFormat,
) {
if videoFormat.Type != enums.MediaTypeVideo {
return
}
if videoFormat.AudioCodec != "" {
return
}
// video with no audio
audioFormat := media.GetDefaultAudioFormat()
if audioFormat == nil {
return
}
videoFormat.AudioCodec = audioFormat.AudioCodec
videoFormat.Plugins = append(videoFormat.Plugins, plugins.MergeAudio)
}

44
bot/handlers/ext.go Normal file
View file

@ -0,0 +1,44 @@
package handlers
import (
extractors "govd/ext"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func ExtractorsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
ctx.CallbackQuery.Answer(bot, nil)
messageText := "available extractors:\n"
extractorNames := make([]string, 0, len(extractors.List))
for _, extractor := range extractors.List {
if extractor.IsRedirect {
continue
}
extractorNames = append(extractorNames, extractor.Name)
}
messageText += strings.Join(extractorNames, ", ")
ctx.EffectiveMessage.EditText(
bot,
messageText,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}

45
bot/handlers/help.go Normal file
View file

@ -0,0 +1,45 @@
package handlers
import (
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var helpMessage = "usage:\n" +
"- you can add the bot to a group " +
"to start catching sent links\n" +
"- you can send a link to the bot privately " +
"to download the media too\n\n" +
"group commands:\n" +
"- /settings = show current settings\n" +
"- /captions (true|false) = enable/disable descriptions\n" +
"- /nsfw (true|false) = enable/disable nsfw content\n" +
"- /limit (int) = set max items in media groups\n\n" +
"note: the bot is still in beta, " +
"so expect some bugs and missing features.\n"
var helpKeyboard = gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
}
func HelpHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
helpMessage,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: helpKeyboard,
},
)
return nil
}

91
bot/handlers/inline.go Normal file
View file

@ -0,0 +1,91 @@
package handlers
import (
"context"
"govd/bot/core"
"govd/models"
"govd/util"
"strings"
"time"
extractors "govd/ext"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func InlineDownloadHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
url := strings.TrimSpace(ctx.InlineQuery.Query)
if url == "" {
ctx.InlineQuery.Answer(bot, []gotgbot.InlineQueryResult{}, &gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
})
return nil
}
dlCtx, err := extractors.CtxByURL(url)
if err != nil || dlCtx == nil || dlCtx.Extractor == nil {
ctx.InlineQuery.Answer(bot, []gotgbot.InlineQueryResult{}, &gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
})
return nil
}
return core.HandleInline(bot, ctx, dlCtx)
}
func InlineDownloadResultHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
dlCtx, ok := core.InlineTasks[ctx.ChosenInlineResult.ResultId]
if !ok {
return nil
}
defer delete(core.InlineTasks, ctx.ChosenInlineResult.ResultId)
mediaChan := make(chan *models.Media, 1)
errChan := make(chan error, 1)
timeout, cancel := context.WithTimeout(
context.Background(),
5*time.Minute,
)
defer cancel()
go core.GetInlineFormat(
bot, ctx, dlCtx,
mediaChan, errChan,
)
select {
case media := <-mediaChan:
err := core.HandleInlineCachedResult(
bot, ctx,
dlCtx, media,
)
if err != nil {
core.HandleErrorMessage(bot, ctx, err)
return nil
}
case err := <-errChan:
core.HandleErrorMessage(bot, ctx, err)
return nil
case <-timeout.Done():
core.HandleErrorMessage(bot, ctx, util.ErrTimeout)
return nil
}
return nil
}
func InlineLoadingHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
ctx.CallbackQuery.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{
Text: "wait !",
ShowAlert: true,
})
return nil
}

72
bot/handlers/instances.go Normal file
View file

@ -0,0 +1,72 @@
package handlers
import (
"fmt"
"os"
"runtime"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var buildHash = "unknown"
var branchName = "unknown"
func getInstanceMessage() string {
return "current instance\n" +
"go version: %s\n" +
"build: <a href='%s'>%s</a>\n" +
"branch: <a href='%s'>%s</a>\n\n" +
"public instances\n" +
"- @govd_bot | main official instance\n" +
"\nwant to add your own instance? reach us on @govdsupport"
}
func InstancesHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
var commitURL string
var branchURL string
repoURL := os.Getenv("REPO_URL")
if repoURL != "" {
commitURL = fmt.Sprintf(
"%s/tree/%s",
repoURL,
buildHash,
)
branchURL = fmt.Sprintf(
"%s/tree/%s",
repoURL,
branchName,
)
}
messageText := fmt.Sprintf(
getInstanceMessage(),
strings.TrimPrefix(runtime.Version(), "go"),
commitURL,
buildHash,
branchURL,
branchName,
)
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
messageText,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}

213
bot/handlers/settings.go Normal file
View file

@ -0,0 +1,213 @@
package handlers
import (
"fmt"
"govd/database"
"govd/util"
"strconv"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func SettingsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
ctx.EffectiveMessage.Reply(
bot,
"use this command in group chats only",
nil,
)
return nil
}
settings, err := database.GetGroupSettings(ctx.EffectiveMessage.Chat.Id)
if err != nil {
return err
}
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf(
"settings for this group\n\ncaptions: %s\nnsfw: %s\nmedia group limit: %d",
strconv.FormatBool(*settings.Captions),
strconv.FormatBool(*settings.NSFW),
settings.MediaGroupLimit,
),
nil,
)
return nil
}
func CaptionsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /captions (true|false)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
userInput := strings.ToLower(args[1])
value, err := strconv.ParseBool(userInput)
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use true or false", userInput),
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.Captions = &value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
var message string
if value {
message = "captions enabled"
} else {
message = "captions disabled"
}
ctx.EffectiveMessage.Reply(
bot,
message,
nil,
)
return nil
}
func NSFWHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /nsfw (true|false)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
userInput := strings.ToLower(args[1])
value, err := strconv.ParseBool(userInput)
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use true or false", userInput),
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.NSFW = &value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
var message string
if value {
message = "nsfw enabled"
} else {
message = "nsfw disabled"
}
ctx.EffectiveMessage.Reply(
bot,
message,
nil,
)
return nil
}
func MediaGroupLimitHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /limit (int)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
value, err := strconv.Atoi(args[1])
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use a number", args[1]),
nil,
)
return nil
}
if value < 1 || value > 20 {
ctx.EffectiveMessage.Reply(
bot,
"media group limit must be between 1 and 20",
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.MediaGroupLimit = value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("media group limit set to %d", value),
nil,
)
return nil
}

88
bot/handlers/start.go Normal file
View file

@ -0,0 +1,88 @@
package handlers
import (
"fmt"
"os"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var startMessage = "govd is an open-source telegram bot " +
"that allows you to download medias from " +
"various platforms. the project born after " +
"the discontinuation of an " +
"highly popular bot, known as UVD."
func getStartKeyboard(bot *gotgbot.Bot) gotgbot.InlineKeyboardMarkup {
return gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "add to group",
Url: fmt.Sprintf(
"https://t.me/%s?startgroup=true",
bot.Username,
),
},
},
{
{
Text: "usage",
CallbackData: "help",
},
{
Text: "stats",
CallbackData: "stats",
},
},
{
{
Text: "extractors",
CallbackData: "extractors",
},
{
Text: "support",
Url: "https://t.me/govdsupport",
},
},
{
{
Text: "instances",
CallbackData: "instances",
},
{
Text: "github",
Url: os.Getenv("REPO_URL"),
},
},
},
}
}
func StartHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type != "private" {
return nil
}
keyboard := getStartKeyboard(bot)
if ctx.Update.Message != nil {
ctx.EffectiveMessage.Reply(
bot,
startMessage,
&gotgbot.SendMessageOpts{
ReplyMarkup: &keyboard,
},
)
} else if ctx.Update.CallbackQuery != nil {
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
startMessage,
&gotgbot.EditMessageTextOpts{
ReplyMarkup: keyboard,
},
)
}
return nil
}

89
bot/handlers/stats.go Normal file
View file

@ -0,0 +1,89 @@
package handlers
import (
"fmt"
"govd/database"
"time"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
type Stats struct {
TotalUsers int64
TotalGroups int64
TotalDailyUsers int64
TotalMedia int64
UpdatedAt time.Time
}
var lastSavedStats *Stats
var statsMessage = "users: %d\nusers today: %d\ngroups: %d\ndownloads: %d\n\nupdates every 10 minutes"
func StatsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type != "private" {
return nil
}
ctx.CallbackQuery.Answer(bot, nil)
stats := GetStats()
ctx.EffectiveMessage.EditText(
bot,
fmt.Sprintf(
statsMessage,
stats.TotalUsers,
stats.TotalDailyUsers,
stats.TotalGroups,
stats.TotalMedia,
),
&gotgbot.EditMessageTextOpts{
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}
func UpdateStats() {
totalUsers, err := database.GetUsersCount()
if err != nil {
return
}
totalGroups, err := database.GetGroupsCount()
if err != nil {
return
}
totalDailyUsers, err := database.GetDailyUserCount()
if err != nil {
return
}
totalMedia, err := database.GetMediaCount()
if err != nil {
return
}
lastSavedStats = &Stats{
TotalUsers: totalUsers,
TotalGroups: totalGroups,
TotalDailyUsers: totalDailyUsers,
TotalMedia: totalMedia,
UpdatedAt: time.Now(),
}
}
func GetStats() *Stats {
if lastSavedStats == nil {
UpdateStats()
}
if lastSavedStats.UpdatedAt.Add(10 * time.Minute).Before(time.Now()) {
UpdateStats()
}
return lastSavedStats
}

65
bot/handlers/url.go Normal file
View file

@ -0,0 +1,65 @@
package handlers
import (
"govd/bot/core"
"govd/database"
extractors "govd/ext"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message"
)
func URLHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
messageURL := getMessageURL(ctx.EffectiveMessage)
if messageURL == "" {
return nil
}
dlCtx, err := extractors.CtxByURL(messageURL)
if err != nil {
core.HandleErrorMessage(
bot, ctx, err)
return nil
}
if dlCtx == nil || dlCtx.Extractor == nil {
return nil
}
userID := ctx.EffectiveMessage.From.Id
if ctx.EffectiveMessage.Chat.Type != "private" {
settings, err := database.GetGroupSettings(ctx.EffectiveMessage.Chat.Id)
if err != nil {
return err
}
dlCtx.GroupSettings = settings
}
if userID != 1087968824 {
// groupAnonymousBot
_, err = database.GetUser(userID)
if err != nil {
return err
}
}
err = core.HandleDownloadRequest(bot, ctx, dlCtx)
if err != nil {
core.HandleErrorMessage(
bot, ctx, err)
}
return nil
}
func URLFilter(msg *gotgbot.Message) bool {
return message.Text(msg) && !message.Command(msg) && containsURL(msg)
}
func containsURL(msg *gotgbot.Message) bool {
return message.Entity("url")(msg)
}
func getMessageURL(msg *gotgbot.Message) string {
for _, entity := range msg.Entities {
if entity.Type == "url" {
return msg.Text[entity.Offset : entity.Offset+entity.Length]
}
}
return ""
}

118
bot/main.go Normal file
View file

@ -0,0 +1,118 @@
package bot
import (
"log"
"os"
"time"
botHandlers "govd/bot/handlers"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/choseninlineresult"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/inlinequery"
)
var AllowedUpdates = []string{
"message",
"callback_query",
"inline_query",
"chosen_inline_result",
}
func Start() {
token := os.Getenv("BOT_TOKEN")
if token == "" {
log.Fatalf("BOT_TOKEN is not provided")
}
b, err := gotgbot.NewBot(token, &gotgbot.BotOpts{
BotClient: NewBotClient(),
})
if err != nil {
log.Fatalf("failed to create bot: %v", err)
}
dispatcher := ext.NewDispatcher(&ext.DispatcherOpts{
Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction {
log.Println("an error occurred while handling update:", err.Error())
return ext.DispatcherActionNoop
},
MaxRoutines: ext.DefaultMaxRoutines,
})
updater := ext.NewUpdater(dispatcher, nil)
registerHandlers(dispatcher)
err = updater.StartPolling(b, &ext.PollingOpts{
DropPendingUpdates: true,
GetUpdatesOpts: &gotgbot.GetUpdatesOpts{
Timeout: 9 * 60,
RequestOpts: &gotgbot.RequestOpts{
Timeout: time.Minute * 10,
},
AllowedUpdates: AllowedUpdates,
},
})
if err != nil {
log.Fatalf("failed to start polling: %v", err)
}
log.Printf("bot started on: %s\n", b.User.Username)
}
func registerHandlers(dispatcher *ext.Dispatcher) {
dispatcher.AddHandler(handlers.NewMessage(
botHandlers.URLFilter,
botHandlers.URLHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"start",
botHandlers.StartHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("start"),
botHandlers.StartHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("help"),
botHandlers.HelpHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"settings",
botHandlers.SettingsHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"captions",
botHandlers.CaptionsHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"nsfw",
botHandlers.NSFWHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"limit",
botHandlers.MediaGroupLimitHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("stats"),
botHandlers.StatsHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("extractors"),
botHandlers.ExtractorsHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("instances"),
botHandlers.InstancesHandler,
))
dispatcher.AddHandler(handlers.NewInlineQuery(
inlinequery.All,
botHandlers.InlineDownloadHandler,
))
dispatcher.AddHandler(handlers.NewChosenInlineResult(
choseninlineresult.All,
botHandlers.InlineDownloadResultHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("inline:loading"),
botHandlers.InlineLoadingHandler,
))
}

56
bot/middleware.go Normal file
View file

@ -0,0 +1,56 @@
package bot
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/PaulSonOfLars/gotgbot/v2"
)
type BotClient struct {
gotgbot.BotClient
}
func (b BotClient) RequestWithContext(
ctx context.Context,
token string,
method string,
params map[string]string,
data map[string]gotgbot.FileReader,
opts *gotgbot.RequestOpts,
) (json.RawMessage, error) {
if strings.HasPrefix(method, "send") || method == "copyMessage" {
params["allow_sending_without_reply"] = "true"
}
if strings.HasPrefix(method, "send") || strings.HasPrefix(method, "edit") {
params["parse_mode"] = "HTML"
}
val, err := b.BotClient.RequestWithContext(ctx, token, method, params, data, opts)
if err != nil {
return nil, err
}
return val, err
}
func NewBotClient() BotClient {
botAPIURL := os.Getenv("BOT_API_URL")
if botAPIURL == "" {
log.Println("BOT_API_URL is not provided, using default")
botAPIURL = gotgbot.DefaultAPIURL
}
return BotClient{
BotClient: &gotgbot.BaseBotClient{
Client: http.Client{},
UseTestEnvironment: false,
DefaultRequestOpts: &gotgbot.RequestOpts{
Timeout: 10 * time.Minute,
APIURL: botAPIURL,
},
},
}
}

17
build.sh Normal file
View file

@ -0,0 +1,17 @@
#!/bin/bash
COMMIT_HASH=$(git rev-parse --short HEAD)
BRANCH_NAME=$(git branch --show-current)
PACKAGE_PATH="govd/bot/handlers"
echo "Building with commit hash: ${COMMIT_HASH}"
echo "Branch name: ${BRANCH_NAME}"
go build -ldflags="-X '${PACKAGE_PATH}.buildHash=${COMMIT_HASH}' -X '${PACKAGE_PATH}.branchName=${BRANCH_NAME}'"
if [ $? -eq 0 ]; then
echo "Build completed successfully"
else
echo "Build failed"
exit 1
fi

1
cookies/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.txt

65
database/main.go Normal file
View file

@ -0,0 +1,65 @@
package database
import (
"govd/models"
"fmt"
"log"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Start() {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_NAME")
connectionString := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True",
user, password, host, port, dbname,
)
db, err := gorm.Open(mysql.Open(connectionString), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time {
utc, _ := time.LoadLocation("Europe/Rome")
return time.Now().In(utc)
},
})
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
DB = db
sqlDB, err := DB.DB()
if err != nil {
log.Fatalf("failed to get database connection: %v", err)
}
err = sqlDB.Ping()
if err != nil {
log.Fatalf("failed to ping database: %v", err)
}
err = migrateDatabase()
if err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
}
func migrateDatabase() error {
err := DB.AutoMigrate(
&models.Media{},
&models.MediaFormat{},
&models.GroupSettings{},
&models.User{},
)
if err != nil {
return err
}
return nil
}

60
database/media.go Normal file
View file

@ -0,0 +1,60 @@
package database
import (
"fmt"
"govd/models"
"gorm.io/gorm"
)
func GetDefaultMedias(
extractorCodeName string,
contentID string,
) ([]*models.Media, error) {
var mediaList []*models.Media
err := DB.
Where(&models.Media{
ExtractorCodeName: extractorCodeName,
ContentID: contentID,
}).
Preload("Format", "is_default = ?", true).
Find(&mediaList).
Error
if err != nil {
return nil, fmt.Errorf("failed to get stored media list: %w", err)
}
return mediaList, nil
}
func StoreMedia(
extractorCodeName string,
contentID string,
media *models.Media,
) error {
return DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where(models.Media{
ExtractorCodeName: extractorCodeName,
ContentID: contentID,
}).FirstOrCreate(&media).Error; err != nil {
return fmt.Errorf("failed to get or create media: %w", err)
}
if media.Format != nil {
format := media.Format
format.MediaID = media.ID
if err := tx.Where(models.MediaFormat{
MediaID: format.MediaID,
FormatID: format.FormatID,
Type: format.Type,
}).FirstOrCreate(format).Error; err != nil {
return fmt.Errorf("failed to get or create format: %w", err)
}
}
return nil
})
}

37
database/settings.go Normal file
View file

@ -0,0 +1,37 @@
package database
import (
"govd/models"
)
func GetGroupSettings(
chatID int64,
) (*models.GroupSettings, error) {
var groupSettings models.GroupSettings
err := DB.
Where(&models.GroupSettings{
ChatID: chatID,
}).
FirstOrCreate(&groupSettings).
Error
if err != nil {
return nil, err
}
return &groupSettings, nil
}
func UpdateGroupSettings(
chatID int64,
settings *models.GroupSettings,
) error {
err := DB.
Where(&models.GroupSettings{
ChatID: chatID,
}).
Updates(settings).
Error
if err != nil {
return err
}
return nil
}

52
database/stats.go Normal file
View file

@ -0,0 +1,52 @@
package database
import "govd/models"
func GetMediaCount() (int64, error) {
var count int64
err := DB.
Model(&models.Media{}).
Count(&count).
Error
if err != nil {
return 0, err
}
return count, nil
}
func GetUsersCount() (int64, error) {
var count int64
err := DB.
Model(&models.User{}).
Count(&count).
Error
if err != nil {
return 0, err
}
return count, nil
}
func GetGroupsCount() (int64, error) {
var count int64
err := DB.
Model(&models.GroupSettings{}).
Count(&count).
Error
if err != nil {
return 0, err
}
return count, nil
}
func GetDailyUserCount() (int64, error) {
var count int64
err := DB.
Model(&models.User{}).
Where("DATE(last_used) = DATE(NOW())").
Count(&count).
Error
if err != nil {
return 0, err
}
return count, nil
}

38
database/user.go Normal file
View file

@ -0,0 +1,38 @@
package database
import "govd/models"
func GetUser(
userID int64,
) (*models.User, error) {
var user models.User
err := DB.
Where(&models.User{
UserID: userID,
}).
FirstOrCreate(&user).
Error
if err != nil {
return nil, err
}
go UpdateUserStatus(userID)
return &user, nil
}
func UpdateUserStatus(
userID int64,
) error {
err := DB.
Model(&models.User{}).
Where(&models.User{
UserID: userID,
}).
Updates(&models.User{
LastUsed: DB.NowFunc(),
}).
Error
if err != nil {
return err
}
return nil
}

8
enums/chat_type.go Normal file
View file

@ -0,0 +1,8 @@
package enums
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
)

View file

@ -0,0 +1,9 @@
package enums
type ExtractorCategory string
const (
ExtractorCategorySocial ExtractorCategory = "social"
ExtractorCategoryStreaming ExtractorCategory = "streaming"
ExtractorCategoryMusic ExtractorCategory = "music"
)

7
enums/extractor_type.go Normal file
View file

@ -0,0 +1,7 @@
package enums
type ExtractorType string
const (
ExtractorTypeSingle ExtractorType = "single"
)

17
enums/media_codec.go Normal file
View file

@ -0,0 +1,17 @@
package enums
type MediaCodec string
const (
MediaCodecAVC MediaCodec = "avc"
MediaCodecHEVC MediaCodec = "hevc"
MediaCodecVP9 MediaCodec = "vp9"
MediaCodecVP8 MediaCodec = "vp8"
MediaCodecAV1 MediaCodec = "av1"
MediaCodecAAC MediaCodec = "aac"
MediaCodecOpus MediaCodec = "opus"
MediaCodecVorbis MediaCodec = "vorbis"
MediaCodecMP3 MediaCodec = "mp3"
MediaCodecFLAC MediaCodec = "flac"
MediaCodecWebP MediaCodec = "webp"
)

9
enums/media_type.go Normal file
View file

@ -0,0 +1,9 @@
package enums
type MediaType string
const (
MediaTypeVideo MediaType = "video"
MediaTypeAudio MediaType = "audio"
MediaTypePhoto MediaType = "photo"
)

169
ext/instagram/main.go Normal file
View file

@ -0,0 +1,169 @@
package instagram
import (
"crypto/tls"
"fmt"
"govd/enums"
"govd/models"
"govd/util"
"io"
"net/http"
"regexp"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
// as a public service, we can't use the official API
// so we use igram.world API, a third-party service
// that provides a similar functionality
// feel free to open PR, if you want to
// add support for the official Instagram API
const (
apiHostname = "api.igram.world"
apiKey = "aaeaf2805cea6abef3f9d2b6a666fce62fd9d612a43ab772bb50ce81455112e0"
apiTimestamp = "1742201548873"
// todo: Implement a proper way
// to get the API key and timestamp
)
var HTTPClient = &http.Client{
Transport: &http3.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
QUICConfig: &quic.Config{
MaxIncomingStreams: -1,
EnableDatagrams: true,
},
},
}
var Extractor = &models.Extractor{
Name: "Instagram",
CodeName: "instagram",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https:\/\/www\.instagram\.com\/(reel|p|tv)\/(?P<id>[a-zA-Z0-9_-]+)`),
IsRedirect: false,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
mediaList, err := MediaListFromAPI(ctx, false)
return &models.ExtractorResponse{
MediaList: mediaList,
}, err
},
}
var StoriesExtractor = &models.Extractor{
Name: "Instagram Stories",
CodeName: "instagram:stories",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https:\/\/www\.instagram\.com\/stories\/[a-zA-Z0-9._]+\/(?P<id>\d+)`),
IsRedirect: false,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
mediaList, err := MediaListFromAPI(ctx, true)
return &models.ExtractorResponse{
MediaList: mediaList,
}, err
},
}
func MediaListFromAPI(
ctx *models.DownloadContext,
stories bool,
) ([]*models.Media, error) {
var mediaList []*models.Media
postURL := ctx.MatchedContentURL
details, err := GetVideoAPI(postURL)
if err != nil {
return nil, fmt.Errorf("failed to get post: %w", err)
}
var caption string
if !stories {
caption, err = GetPostCaption(postURL)
if err != nil {
return nil, fmt.Errorf("failed to get caption: %w", err)
}
}
for _, item := range details.Items {
media := ctx.Extractor.NewMedia(
ctx.MatchedContentID,
ctx.MatchedContentURL,
)
media.SetCaption(caption)
urlObj := item.URL[0]
contentURL, err := GetCDNURL(urlObj.URL)
if err != nil {
return nil, err
}
thumbnailURL, err := GetCDNURL(item.Thumb)
if err != nil {
return nil, err
}
fileExt := urlObj.Ext
formatID := urlObj.Type
switch fileExt {
case "mp4":
media.AddFormat(&models.MediaFormat{
Type: enums.MediaTypeVideo,
FormatID: formatID,
URL: []string{contentURL},
VideoCodec: enums.MediaCodecAVC,
AudioCodec: enums.MediaCodecAAC,
Thumbnail: []string{thumbnailURL},
},
)
case "jpg", "webp", "heic", "jpeg":
media.AddFormat(&models.MediaFormat{
Type: enums.MediaTypePhoto,
FormatID: formatID,
URL: []string{contentURL},
})
default:
return nil, fmt.Errorf("unknown format: %s", fileExt)
}
mediaList = append(mediaList, media)
}
return mediaList, nil
}
func GetVideoAPI(contentURL string) (*IGramResponse, error) {
apiURL := fmt.Sprintf(
"https://%s/api/convert",
apiHostname,
)
payload, err := BuildSignedPayload(contentURL)
if err != nil {
return nil, fmt.Errorf("failed to build signed payload: %w", err)
}
req, err := http.NewRequest("POST", apiURL, payload)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", util.ChromeUA)
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get response: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
response, err := ParseIGramResponse(body)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return response, nil
}

19
ext/instagram/models.go Normal file
View file

@ -0,0 +1,19 @@
package instagram
type IGramResponse struct {
Items []*IGramMedia `json:"items"`
}
type IGramMedia struct {
URL []*MediaURL `json:"url"`
Thumb string `json:"thumb"`
Hosting string `json:"hosting"`
Timestamp int `json:"timestamp"`
}
type MediaURL struct {
URL string `json:"url"`
Name string `json:"name"`
Type string `json:"type"`
Ext string `json:"ext"`
}

139
ext/instagram/util.go Normal file
View file

@ -0,0 +1,139 @@
package instagram
import (
"crypto/sha256"
"encoding/json"
"fmt"
"govd/util"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var captionPattern = regexp.MustCompile(
`(?s)<meta property="og:title" content=".*?: &quot;(.*?)&quot;"`,
)
func BuildSignedPayload(contentURL string) (io.Reader, error) {
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
hash := sha256.New()
_, err := io.WriteString(
hash,
contentURL+timestamp+apiKey,
)
if err != nil {
return nil, fmt.Errorf("error writing to SHA256 hash: %w", err)
}
secretBytes := hash.Sum(nil)
secretString := fmt.Sprintf("%x", secretBytes)
secretString = strings.ToLower(secretString)
payload := map[string]string{
"url": contentURL,
"ts": timestamp,
"_ts": apiTimestamp,
"_tsc": "0", // ?
"_s": secretString,
}
parsedPayload, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("error marshalling payload: %w", err)
}
reader := strings.NewReader(string(parsedPayload))
return reader, nil
}
func ParseIGramResponse(body []byte) (*IGramResponse, error) {
var rawResponse interface{}
if err := json.Unmarshal(body, &rawResponse); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
switch rawResponse.(type) {
case []interface{}:
// array of IGramMedia
var media []*IGramMedia
if err := json.Unmarshal(body, &media); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &IGramResponse{
Items: media,
}, nil
case map[string]interface{}:
// single IGramMedia
var media IGramMedia
if err := json.Unmarshal(body, &media); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &IGramResponse{
Items: []*IGramMedia{&media},
}, nil
default:
return nil, fmt.Errorf("unexpected response type: %T", rawResponse)
}
}
func GetCDNURL(contentURL string) (string, error) {
parsedUrl, err := url.Parse(contentURL)
if err != nil {
return "", fmt.Errorf("can't parse igram URL: %v", err)
}
queryParams, err := url.ParseQuery(parsedUrl.RawQuery)
if err != nil {
return "", fmt.Errorf("can't unescape igram URL: %v", err)
}
cdnURL := queryParams.Get("uri")
return cdnURL, nil
}
func GetPostCaption(
postURL string,
) (string, error) {
req, err := http.NewRequest(
http.MethodGet,
postURL,
nil,
)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", util.ChromeUA)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "it-IT,it;q=0.8,en-US;q=0.5,en;q=0.3")
req.Header.Set("Referer", "https://www.instagram.com/accounts/onetap/?next=%2F")
req.Header.Set("Alt-Used", "www.instagram.com")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Cookie", `csrftoken=Ib2Zuvf1y9HkDwXFxkdang; sessionid=8569455296%3AIFQiov2eYfTdSd%3A19%3AAYfVHnaxecWGWhyzxvz60vu5qLn05DyKgN_tTZUXTA; ds_user_id=8569455296; mid=Z_j1vQAEAAGVUE3KuxMR7vBonGBw; ig_did=BC48C8B7-D71B-49EF-8195-F9DE37A57B49; rur="CLN\0548569455296\0541775905137:01f7ebda5b896815e9279bb86a572db6bdc8ebccf3e1f8d5327e2bc5ca187fd5cd932b66"; wd=513x594; datr=x_X4Z_CHqpwtjaRKq7PtCNu3`)
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Priority", "u=0, i")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("TE", "trailers")
resp, err := HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get response: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
matches := captionPattern.FindStringSubmatch(string(body))
if len(matches) < 2 {
// post has no caption most likely
return "", nil
} else {
return html.UnescapeString(matches[1]), nil
}
}

24
ext/main.go Normal file
View file

@ -0,0 +1,24 @@
package ext
import (
"govd/ext/instagram"
"govd/ext/pinterest"
"govd/ext/reddit"
"govd/ext/tiktok"
"govd/ext/twitter"
"govd/models"
)
var List = []*models.Extractor{
tiktok.Extractor,
tiktok.VMExtractor,
instagram.Extractor,
instagram.StoriesExtractor,
twitter.Extractor,
twitter.ShortExtractor,
pinterest.Extractor,
pinterest.ShortExtractor,
reddit.Extractor,
reddit.ShortExtractor,
// todo: add every ext lol
}

172
ext/pinterest/main.go Normal file
View file

@ -0,0 +1,172 @@
package pinterest
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"govd/enums"
"govd/models"
"govd/util"
)
const (
pinResourceEndpoint = "https://www.pinterest.com/resource/PinResource/get/"
shortenerAPIFormat = "https://api.pinterest.com/url_shortener/%s/redirect/"
)
var ShortExtractor = &models.Extractor{
Name: "Pinterest (Short)",
CodeName: "pinterest:short",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?://(\w+\.)?pin\.\w+/(?P<id>\w+)`),
IsRedirect: true,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
shortURL := fmt.Sprintf(shortenerAPIFormat, ctx.MatchedContentID)
location, err := util.GetLocationURL(shortURL, "")
if err != nil {
return nil, fmt.Errorf("failed to get real url: %w", err)
}
return &models.ExtractorResponse{
URL: location,
}, nil
},
}
var Extractor = &models.Extractor{
Name: "Pinterest",
CodeName: "pinterest",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?://(\w+\.)?pinterest[\.\w]+/pin/(?P<id>\d+)`),
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
media, err := ExtractPinMedia(ctx)
if err != nil {
return nil, err
}
return &models.ExtractorResponse{
MediaList: media,
}, nil
},
}
func ExtractPinMedia(ctx *models.DownloadContext) ([]*models.Media, error) {
pinID := ctx.MatchedContentID
contentURL := ctx.MatchedContentURL
pinData, err := GetPinData(pinID)
if err != nil {
return nil, err
}
media := ctx.Extractor.NewMedia(pinID, contentURL)
media.SetCaption(pinData.Title)
if pinData.Videos != nil && pinData.Videos.VideoList != nil {
formats, err := ParseVideoObject(pinData.Videos)
if err != nil {
return nil, err
}
for _, format := range formats {
media.AddFormat(format)
}
return []*models.Media{media}, nil
}
if pinData.StoryPinData != nil && len(pinData.StoryPinData.Pages) > 0 {
for _, page := range pinData.StoryPinData.Pages {
for _, block := range page.Blocks {
if block.BlockType == 3 && block.Video != nil { // blockType 3 = Video
formats, err := ParseVideoObject(block.Video)
if err != nil {
return nil, err
}
for _, format := range formats {
media.AddFormat(format)
}
return []*models.Media{media}, nil
}
}
}
}
if pinData.Images != nil && pinData.Images.Orig != nil {
imageURL := pinData.Images.Orig.URL
media.AddFormat(&models.MediaFormat{
FormatID: "photo",
Type: enums.MediaTypePhoto,
URL: []string{imageURL},
})
return []*models.Media{media}, nil
} else if pinData.StoryPinData != nil && len(pinData.StoryPinData.Pages) > 0 {
for _, page := range pinData.StoryPinData.Pages {
if page.Image != nil && page.Image.Images.Originals != nil {
media.AddFormat(&models.MediaFormat{
FormatID: "photo",
Type: enums.MediaTypePhoto,
URL: []string{page.Image.Images.Originals.URL},
})
return []*models.Media{media}, nil
}
}
}
if pinData.Embed != nil && pinData.Embed.Type == "gif" {
media.AddFormat(&models.MediaFormat{
FormatID: "gif",
Type: enums.MediaTypeVideo,
VideoCodec: enums.MediaCodecAVC,
URL: []string{pinData.Embed.Src},
})
return []*models.Media{media}, nil
}
return nil, fmt.Errorf("no media found for pin ID: %s", pinID)
}
func GetPinData(pinID string) (*PinData, error) {
params := BuildPinRequestParams(pinID)
req, err := http.NewRequest("GET", pinResourceEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
q := req.URL.Query()
for key, value := range params {
q.Add(key, value)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", util.ChromeUA)
// fix 403 error
req.Header.Set("X-Pinterest-PWS-Handler", "www/[username].js")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad response: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var pinResponse PinResponse
err = json.Unmarshal(body, &pinResponse)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &pinResponse.ResourceResponse.Data, nil
}

62
ext/pinterest/models.go Normal file
View file

@ -0,0 +1,62 @@
package pinterest
type PinResponse struct {
ResourceResponse struct {
Data PinData `json:"data"`
} `json:"resource_response"`
}
type PinData struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Images *Images `json:"images,omitempty"`
Videos *Videos `json:"videos,omitempty"`
StoryPinData *StoryPin `json:"story_pin_data,omitempty"`
Embed *Embed `json:"embed,omitempty"`
}
type Images struct {
Orig *ImageObject `json:"orig"`
}
type ImageObject struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
type Videos struct {
VideoList map[string]*VideoObject `json:"video_list"`
}
type VideoObject struct {
URL string `json:"url"`
Width int64 `json:"width"`
Height int64 `json:"height"`
Duration int64 `json:"duration"`
Thumbnail string `json:"thumbnail"`
}
type StoryPin struct {
Pages []Page `json:"pages"`
}
type Page struct {
Blocks []Block `json:"blocks"`
Image *struct {
Images struct {
Originals *ImageObject `json:"originals"`
} `json:"images"`
} `json:"image,omitempty"`
}
type Block struct {
BlockType int `json:"block_type"`
Video *Videos `json:"video,omitempty"`
}
type Embed struct {
Type string `json:"type"`
Src string `json:"src"`
}

55
ext/pinterest/util.go Normal file
View file

@ -0,0 +1,55 @@
package pinterest
import (
"encoding/json"
"fmt"
"govd/enums"
"govd/models"
"govd/util/parser"
)
func ParseVideoObject(videoObj *Videos) ([]*models.MediaFormat, error) {
var formats []*models.MediaFormat
for key, video := range videoObj.VideoList {
if key != "HLS" {
formats = append(formats, &models.MediaFormat{
FormatID: key,
URL: []string{video.URL},
Type: enums.MediaTypeVideo,
VideoCodec: enums.MediaCodecAVC,
AudioCodec: enums.MediaCodecAAC,
Width: video.Width,
Height: video.Height,
Duration: video.Duration / 1000,
Thumbnail: []string{video.Thumbnail},
})
} else {
hlsFormats, err := parser.ParseM3U8FromURL(video.URL)
if err != nil {
return nil, fmt.Errorf("failed to extract hls formats: %w", err)
}
for _, hlsFormat := range hlsFormats {
hlsFormat.Duration = video.Duration / 1000
hlsFormat.Thumbnail = []string{video.Thumbnail}
formats = append(formats, hlsFormat)
}
}
}
return formats, nil
}
func BuildPinRequestParams(pinID string) map[string]string {
options := map[string]interface{}{
"options": map[string]interface{}{
"field_set_key": "unauth_react_main_pin",
"id": pinID,
},
}
jsonData, _ := json.Marshal(options)
return map[string]string{
"data": string(jsonData),
}
}

267
ext/reddit/main.go Normal file
View file

@ -0,0 +1,267 @@
package reddit
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"govd/enums"
"govd/models"
"govd/util"
)
var HTTPClient = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
}
var ShortExtractor = &models.Extractor{
Name: "Reddit (Short)",
CodeName: "reddit:short",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?://(?P<host>(?:\w+\.)?reddit(?:media)?\.com)/(?P<slug>(?:(?:r|user)/[^/]+/)?s/(?P<id>[^/?#&]+))`),
IsRedirect: true,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
req, err := http.NewRequest("GET", ctx.MatchedContentURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", util.ChromeUA)
cookies, err := util.ParseCookieFile("reddit.txt")
if err != nil {
return nil, fmt.Errorf("failed to get cookies: %w", err)
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}
res, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer res.Body.Close()
location := res.Request.URL.String()
return &models.ExtractorResponse{
URL: location,
}, nil
},
}
var Extractor = &models.Extractor{
Name: "Reddit",
CodeName: "reddit",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?://(?P<host>(?:\w+\.)?reddit(?:media)?\.com)/(?P<slug>(?:(?:r|user)/[^/]+/)?comments/(?P<id>[^/?#&]+))`),
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
mediaList, err := MediaListFromAPI(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get media: %w", err)
}
return &models.ExtractorResponse{
MediaList: mediaList,
}, nil
},
}
func MediaListFromAPI(ctx *models.DownloadContext) ([]*models.Media, error) {
host := ctx.MatchedGroups["host"]
slug := ctx.MatchedGroups["slug"]
contentID := ctx.MatchedContentID
contentURL := ctx.MatchedContentURL
manifest, err := GetRedditData(host, slug)
if err != nil {
return nil, err
}
if len(manifest) == 0 || len(manifest[0].Data.Children) == 0 {
return nil, fmt.Errorf("no data found in response")
}
data := manifest[0].Data.Children[0].Data
title := data.Title
isNsfw := data.Over18
var mediaList []*models.Media
if !data.IsVideo {
// check for single photo
if data.Preview != nil && len(data.Preview.Images) > 0 {
media := ctx.Extractor.NewMedia(contentID, contentURL)
media.SetCaption(title)
if isNsfw {
media.NSFW = true
}
image := data.Preview.Images[0]
// check for video preview (GIF)
if data.Preview.RedditVideoPreview != nil {
formats, err := GetHLSFormats(
data.Preview.RedditVideoPreview.FallbackURL,
image.Source.URL,
data.Preview.RedditVideoPreview.Duration,
)
if err != nil {
return nil, err
}
for _, format := range formats {
media.AddFormat(format)
}
mediaList = append(mediaList, media)
return mediaList, nil
}
// check for MP4 variant (animated GIF)
if image.Variants.MP4 != nil {
media.AddFormat(&models.MediaFormat{
FormatID: "gif",
Type: enums.MediaTypeVideo,
VideoCodec: enums.MediaCodecAVC,
AudioCodec: enums.MediaCodecAAC,
URL: []string{util.FixURL(image.Variants.MP4.Source.URL)},
Thumbnail: []string{util.FixURL(image.Source.URL)},
})
mediaList = append(mediaList, media)
return mediaList, nil
}
// regular photo
media.AddFormat(&models.MediaFormat{
FormatID: "photo",
Type: enums.MediaTypePhoto,
URL: []string{util.FixURL(image.Source.URL)},
})
mediaList = append(mediaList, media)
return mediaList, nil
}
// check for gallery/collection
if len(data.MediaMetadata) > 0 {
for key, obj := range data.MediaMetadata {
if obj.E == "Image" {
media := ctx.Extractor.NewMedia(key, contentURL)
media.SetCaption(title)
if isNsfw {
media.NSFW = true
}
media.AddFormat(&models.MediaFormat{
FormatID: "photo",
Type: enums.MediaTypePhoto,
URL: []string{util.FixURL(obj.S.U)},
})
mediaList = append(mediaList, media)
}
}
return mediaList, nil
}
} else {
// video
media := ctx.Extractor.NewMedia(contentID, contentURL)
media.SetCaption(title)
if isNsfw {
media.NSFW = true
}
var redditVideo *RedditVideo
if data.Media != nil && data.Media.RedditVideo != nil {
redditVideo = data.Media.RedditVideo
} else if data.SecureMedia != nil && data.SecureMedia.RedditVideo != nil {
redditVideo = data.SecureMedia.RedditVideo
}
if redditVideo != nil {
thumbnail := data.Thumbnail
if (thumbnail == "nsfw" || thumbnail == "spoiler") && data.Preview != nil && len(data.Preview.Images) > 0 {
thumbnail = data.Preview.Images[0].Source.URL
}
formats, err := GetHLSFormats(
redditVideo.FallbackURL,
thumbnail,
redditVideo.Duration,
)
if err != nil {
return nil, err
}
for _, format := range formats {
media.AddFormat(format)
}
mediaList = append(mediaList, media)
return mediaList, nil
}
}
return mediaList, nil
}
func GetRedditData(host string, slug string) (RedditResponse, error) {
url := fmt.Sprintf("https://%s/%s/.json", host, slug)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", util.ChromeUA)
cookies, err := util.ParseCookieFile("reddit.txt")
if err != nil {
return nil, fmt.Errorf("failed to get cookies: %w", err)
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}
res, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
// try with alternative domain
altHost := "old.reddit.com"
if host == "old.reddit.com" {
altHost = "www.reddit.com"
}
return GetRedditData(altHost, slug)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var response RedditResponse
err = json.Unmarshal(body, &response)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return response, nil
}

74
ext/reddit/models.go Normal file
View file

@ -0,0 +1,74 @@
package reddit
type RedditResponse []struct {
Data struct {
Children []struct {
Data PostData `json:"data"`
} `json:"children"`
} `json:"data"`
}
type PostData struct {
ID string `json:"id"`
Title string `json:"title"`
IsVideo bool `json:"is_video"`
Thumbnail string `json:"thumbnail"`
Media *Media `json:"media"`
Preview *Preview `json:"preview"`
MediaMetadata map[string]MediaMetadata `json:"media_metadata"`
SecureMedia *Media `json:"secure_media"`
Over18 bool `json:"over_18"`
}
type Media struct {
RedditVideo *RedditVideo `json:"reddit_video"`
}
type RedditVideo struct {
FallbackURL string `json:"fallback_url"`
HLSURL string `json:"hls_url"`
DashURL string `json:"dash_url"`
Duration int64 `json:"duration"`
Height int64 `json:"height"`
Width int64 `json:"width"`
ScrubberMediaURL string `json:"scrubber_media_url"`
}
type Preview struct {
Images []Image `json:"images"`
RedditVideoPreview *RedditVideoPreview `json:"reddit_video_preview"`
}
type Image struct {
Source ImageSource `json:"source"`
Variants ImageVariants `json:"variants"`
}
type ImageSource struct {
URL string `json:"url"`
Width int64 `json:"width"`
Height int64 `json:"height"`
}
type ImageVariants struct {
MP4 *MP4Variant `json:"mp4"`
}
type MP4Variant struct {
Source ImageSource `json:"source"`
}
type RedditVideoPreview struct {
FallbackURL string `json:"fallback_url"`
Duration int64 `json:"duration"`
}
type MediaMetadata struct {
Status string `json:"status"`
E string `json:"e"`
S struct {
U string `json:"u"`
X int64 `json:"x"`
Y int64 `json:"y"`
} `json:"s"`
}

39
ext/reddit/util.go Normal file
View file

@ -0,0 +1,39 @@
package reddit
import (
"fmt"
"govd/models"
"govd/util"
"govd/util/parser"
"regexp"
)
const (
hlsURLFormat = "https://v.redd.it/%s/HLSPlaylist.m3u8"
)
var videoURLPattern = regexp.MustCompile(`https?://v\.redd\.it/([^/]+)`)
func GetHLSFormats(videoURL string, thumbnail string, duration int64) ([]*models.MediaFormat, error) {
matches := videoURLPattern.FindStringSubmatch(videoURL)
if len(matches) < 2 {
return nil, nil
}
videoID := matches[1]
hlsURL := fmt.Sprintf(hlsURLFormat, videoID)
formats, err := parser.ParseM3U8FromURL(hlsURL)
if err != nil {
return nil, err
}
for _, format := range formats {
format.Duration = duration
if thumbnail != "" {
format.Thumbnail = []string{util.FixURL(thumbnail)}
}
}
return formats, nil
}

184
ext/tiktok/main.go Normal file
View file

@ -0,0 +1,184 @@
package tiktok
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"govd/enums"
"govd/models"
"govd/util"
)
const (
apiHostname = "api16-normal-c-useast1a.tiktokv.com"
installationID = "7127307272354596614"
appName = "musical_ly"
appID = "1233"
appVersion = "37.1.4"
manifestAppVersion = "2023508030"
packageID = "com.zhiliaoapp.musically/" + manifestAppVersion
appUserAgent = packageID + " (Linux; U; Android 13; en_US; Pixel 7; Build/TD1A.220804.031; Cronet/58.0.2991.0)"
)
var HTTPClient = &http.Client{
Transport: &http3.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
QUICConfig: &quic.Config{
MaxIncomingStreams: -1,
EnableDatagrams: true,
},
},
}
var VMExtractor = &models.Extractor{
Name: "TikTok VM",
CodeName: "tiktokvm",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https:\/\/((?:vm|vt|www)\.)?(vx)?tiktok\.com\/(?:t\/)?(?P<id>[a-zA-Z0-9]+)`),
IsRedirect: true,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
location, err := util.GetLocationURL(ctx.MatchedContentURL, "")
if err != nil {
return nil, fmt.Errorf("failed to get url location: %w", err)
}
return &models.ExtractorResponse{
URL: location,
}, nil
},
}
var Extractor = &models.Extractor{
Name: "TikTok",
CodeName: "tiktok",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?:\/\/((www|m)\.)?(vx)?tiktok\.com\/((?:embed|@[\w\.-]+)\/)?(v(ideo)?|p(hoto)?)\/(?P<id>[0-9]+)`),
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
mediaList, err := MediaListFromAPI(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get media: %w", err)
}
return &models.ExtractorResponse{
MediaList: mediaList,
}, nil
},
}
func MediaListFromAPI(ctx *models.DownloadContext) ([]*models.Media, error) {
var mediaList []*models.Media
details, err := GetVideoAPI(ctx.MatchedContentID)
if err != nil {
return nil, fmt.Errorf("failed to get from api: %w", err)
}
caption := details.Desc
isImageSlide := details.ImagePostInfo != nil
if !isImageSlide {
media := ctx.Extractor.NewMedia(
ctx.MatchedContentID,
ctx.MatchedContentURL,
)
media.SetCaption(caption)
video := details.Video
// generic PlayAddr
if video.PlayAddr != nil {
format, err := ParsePlayAddr(video, video.PlayAddr)
if err != nil {
return nil, fmt.Errorf("failed to parse playaddr: %w", err)
}
media.AddFormat(format)
}
// hevc PlayAddr
if video.PlayAddrBytevc1 != nil {
format, err := ParsePlayAddr(video, video.PlayAddrBytevc1)
if err != nil {
return nil, fmt.Errorf("failed to parse playaddr: %w", err)
}
media.AddFormat(format)
}
// h264 PlayAddr
if video.PlayAddrH264 != nil {
format, err := ParsePlayAddr(video, video.PlayAddrH264)
if err != nil {
return nil, fmt.Errorf("failed to parse playaddr: %w", err)
}
media.AddFormat(format)
}
mediaList = append(mediaList, media)
} else {
images := details.ImagePostInfo.Images
for _, image := range images {
media := ctx.Extractor.NewMedia(
ctx.MatchedContentID,
ctx.MatchedContentURL,
)
media.SetCaption(caption)
media.AddFormat(&models.MediaFormat{
FormatID: "image",
Type: enums.MediaTypePhoto,
URL: image.DisplayImage.URLList,
})
mediaList = append(mediaList, media)
}
}
return mediaList, nil
}
func GetVideoAPI(awemeID string) (*AwemeDetails, error) {
apiURL := fmt.Sprintf(
"https://%s/aweme/v1/multi/aweme/detail/",
apiHostname,
)
queryParams, err := BuildAPIQuery()
if err != nil {
return nil, fmt.Errorf("failed to build api query: %w", err)
}
postData := BuildPostData(awemeID)
req, err := http.NewRequest(
http.MethodPost,
apiURL,
postData,
)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.URL.RawQuery = queryParams.Encode()
req.Header.Set("User-Agent", appUserAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Argus", "")
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var data *Response
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
videoData, err := FindVideoData(data, awemeID)
if err != nil {
return nil, fmt.Errorf("failed to find video data: %w", err)
}
return videoData, nil
}

65
ext/tiktok/models.go Normal file
View file

@ -0,0 +1,65 @@
package tiktok
type Response struct {
AwemeDetails []AwemeDetails `json:"aweme_details"`
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
}
type Cover struct {
Height int64 `json:"height"`
URI string `json:"uri"`
URLList []string `json:"url_list"`
URLPrefix any `json:"url_prefix"`
Width int64 `json:"width"`
}
type PlayAddr struct {
DataSize int64 `json:"data_size"`
FileCs string `json:"file_cs"`
FileHash string `json:"file_hash"`
Height int64 `json:"height"`
URI string `json:"uri"`
URLKey string `json:"url_key"`
URLList []string `json:"url_list"`
Width int64 `json:"width"`
}
type Image struct {
DisplayImage *DisplayImage `json:"display_image"`
}
type DisplayImage struct {
Height int `json:"height"`
URI string `json:"uri"`
URLList []string `json:"url_list"`
URLPrefix any `json:"url_prefix"`
Width int `json:"width"`
}
type ImagePostInfo struct {
Images []Image `json:"images"`
MusicVolume float64 `json:"music_volume"`
PostExtra string `json:"post_extra"`
Title string `json:"title"`
}
type Video struct {
CdnURLExpired int64 `json:"cdn_url_expired"`
Cover Cover `json:"cover"`
Duration int64 `json:"duration"`
HasWatermark bool `json:"has_watermark"`
Height int64 `json:"height"`
PlayAddr *PlayAddr `json:"play_addr"`
PlayAddrBytevc1 *PlayAddr `json:"play_addr_bytevc1"`
PlayAddrH264 *PlayAddr `json:"play_addr_h264"`
Width int64 `json:"width"`
}
type AwemeDetails struct {
AwemeID string `json:"aweme_id"`
AwemeType int `json:"aweme_type"`
Desc string `json:"desc"`
Video *Video `json:"video"`
ImagePostInfo *ImagePostInfo `json:"image_post_info"`
}

177
ext/tiktok/util.go Normal file
View file

@ -0,0 +1,177 @@
package tiktok
import (
"crypto/rand"
"fmt"
"math/big"
"net/url"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"govd/enums"
"govd/models"
"govd/util"
"github.com/google/uuid"
)
func BuildAPIQuery() (url.Values, error) {
requestTicket := strconv.Itoa(int(time.Now().Unix()) * 1000)
clientDeviceID := uuid.New().String()
versionCode, err := GetAppVersionCode(appVersion)
if err != nil {
return nil, fmt.Errorf("failed to get app version code: %w", err)
}
return url.Values{
"device_platform": []string{"android"},
"os": []string{"android"},
"ssmix": []string{"0"}, // what is this?
"_rticket": []string{requestTicket},
"cdid": []string{clientDeviceID},
"channel": []string{"googleplay"},
"aid": []string{appID},
"app_name": []string{appName},
"version_code": []string{versionCode},
"version_name": []string{appVersion},
"manifest_version_code": []string{manifestAppVersion},
"update_version_code": []string{manifestAppVersion},
"ab_version": []string{appVersion},
"resolution": []string{"1080*2400"},
"dpi": []string{"420"},
"device_type": []string{"Pixel 7"},
"device_brand": []string{"Google"},
"language": []string{"en"},
"os_api": []string{"29"},
"os_version": []string{"13"},
"ac": []string{"wifi"},
"is_pad": []string{"0"},
"current_region": []string{"US"},
"app_type": []string{"normal"},
"last_install_time": []string{GetRandomInstallTime()},
"timezone_name": []string{"America/New_York"},
"residence": []string{"US"},
"app_language": []string{"en"},
"timezone_offset": []string{"-14400"},
"host_abi": []string{"armeabi-v7a"},
"locale": []string{"en"},
"ac2": []string{"wifi5g"},
"uoo": []string{"1"}, // what is this?
"carrier_region": []string{"US"},
"build_number": []string{appVersion},
"region": []string{"US"},
"ts": []string{strconv.Itoa(int(time.Now().Unix()))},
"iid": []string{installationID},
"device_id": []string{GetRandomDeviceID()},
"openudid": []string{GetRandomUdid()},
}, nil
}
func ParsePlayAddr(
video *Video,
playAddr *PlayAddr,
) (*models.MediaFormat, error) {
formatID := playAddr.URLKey
if formatID == "" {
return nil, errors.New("url_key not found")
}
videoCodec := enums.MediaCodecHEVC
if strings.Contains(formatID, "h264") {
videoCodec = enums.MediaCodecAVC
}
videoURL := playAddr.URLList
videoDuration := video.Duration / 1000
videoWidth := playAddr.Width
videoHeight := playAddr.Height
videoCover := &video.Cover
videoThumbnailURLs := videoCover.URLList
return &models.MediaFormat{
Type: enums.MediaTypeVideo,
FormatID: formatID,
URL: videoURL,
VideoCodec: videoCodec,
AudioCodec: enums.MediaCodecAAC,
Duration: videoDuration,
Thumbnail: videoThumbnailURLs,
Width: videoWidth,
Height: videoHeight,
}, nil
}
func GetRandomInstallTime() string {
currentTime := int(time.Now().Unix())
minOffset := big.NewInt(86400)
maxOffset := big.NewInt(1123200)
diff := new(big.Int).Sub(maxOffset, minOffset)
randomOffset, _ := rand.Int(rand.Reader, diff)
randomOffset.Add(randomOffset, minOffset)
result := currentTime - int(randomOffset.Int64())
return strconv.Itoa(result)
}
func GetRandomUdid() string {
const charset = "0123456789abcdef"
result := make([]byte, 16)
for i := range result {
index, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
result[i] = charset[index.Int64()]
}
return string(result)
}
func GetRandomDeviceID() string {
minNum := big.NewInt(7250000000000000000)
maxNum := big.NewInt(7351147085025500000)
diff := new(big.Int).Sub(maxNum, minNum)
randNum, _ := rand.Int(rand.Reader, diff)
result := new(big.Int).Add(randNum, minNum)
return result.String()
}
func BuildPostData(awemeID string) *strings.Reader {
data := url.Values{
"aweme_ids": []string{fmt.Sprintf("[%s]", awemeID)},
"request_source": []string{"0"},
}
return strings.NewReader(data.Encode())
}
func GetAppVersionCode(version string) (string, error) {
parts := strings.Split(version, ".")
var result strings.Builder
for _, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
return "", fmt.Errorf("failed to parse version part: %w", err)
}
_, err = fmt.Fprintf(&result, "%02d", num)
if err != nil {
return "", fmt.Errorf("failed to format version part: %w", err)
}
}
return result.String(), nil
}
func FindVideoData(
resp *Response,
expectedAwemeID string,
) (*AwemeDetails, error) {
if resp.StatusCode == 2053 {
return nil, util.ErrUnavailable
}
if resp.AwemeDetails == nil {
return nil, errors.New("aweme_details is nil")
}
for _, item := range resp.AwemeDetails {
if item.AwemeID == expectedAwemeID {
return &item, nil
}
}
return nil, errors.New("matching aweme_id not found")
}

181
ext/twitter/main.go Normal file
View file

@ -0,0 +1,181 @@
package twitter
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"govd/enums"
"govd/models"
"govd/util"
)
const (
apiHostname = "x.com"
apiEndpoint = "https://x.com/i/api/graphql/zZXycP0V6H7m-2r0mOnFcA/TweetDetail"
)
var HTTPClient = &http.Client{}
var ShortExtractor = &models.Extractor{
Name: "Twitter (Short)",
CodeName: "twitter:short",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?://t\.co/(?P<id>\w+)`),
IsRedirect: true,
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
req, err := http.NewRequest("GET", ctx.MatchedContentURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create req: %w", err)
}
req.Header.Set("User-Agent", util.ChromeUA)
res, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
matchedURL := Extractor.URLPattern.FindStringSubmatch(string(body))
if matchedURL == nil {
return nil, fmt.Errorf("failed to find url in body")
}
return &models.ExtractorResponse{
URL: matchedURL[0],
}, nil
},
}
var Extractor = &models.Extractor{
Name: "Twitter",
CodeName: "twitter",
Type: enums.ExtractorTypeSingle,
Category: enums.ExtractorCategorySocial,
URLPattern: regexp.MustCompile(`https?:\/\/(vx)?(twitter|x)\.com\/([^\/]+)\/status\/(?P<id>\d+)`),
Run: func(ctx *models.DownloadContext) (*models.ExtractorResponse, error) {
mediaList, err := MediaListFromAPI(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get media: %w", err)
}
return &models.ExtractorResponse{
MediaList: mediaList,
}, nil
},
}
func MediaListFromAPI(ctx *models.DownloadContext) ([]*models.Media, error) {
var mediaList []*models.Media
tweetData, err := GetTweetAPI(ctx.MatchedContentID)
if err != nil {
return nil, fmt.Errorf("failed to get tweet data: %w", err)
}
caption := CleanCaption(tweetData.FullText)
var mediaEntities []MediaEntity
if tweetData.ExtendedEntities != nil && len(tweetData.ExtendedEntities.Media) > 0 {
mediaEntities = tweetData.ExtendedEntities.Media
} else if tweetData.Entities != nil && len(tweetData.Entities.Media) > 0 {
mediaEntities = tweetData.Entities.Media
} else {
return nil, fmt.Errorf("no media found in tweet")
}
for _, mediaEntity := range mediaEntities {
media := ctx.Extractor.NewMedia(
ctx.MatchedContentID,
ctx.MatchedContentURL,
)
media.SetCaption(caption)
switch mediaEntity.Type {
case "video", "animated_gif":
formats, err := ExtractVideoFormats(&mediaEntity)
if err != nil {
return nil, err
}
for _, format := range formats {
media.AddFormat(format)
}
case "photo":
media.AddFormat(&models.MediaFormat{
Type: enums.MediaTypePhoto,
FormatID: "photo",
URL: []string{mediaEntity.MediaURLHTTPS},
})
}
if len(media.Formats) > 0 {
mediaList = append(mediaList, media)
}
}
return mediaList, nil
}
func GetTweetAPI(tweetID string) (*Tweet, error) {
cookies, err := util.ParseCookieFile("twitter.txt")
if err != nil {
return nil, fmt.Errorf("failed to get cookies: %w", err)
}
headers := BuildAPIHeaders(cookies)
if headers == nil {
return nil, fmt.Errorf("failed to build headers. check cookies")
}
query := BuildAPIQuery(tweetID)
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create req: %w", err)
}
for key, value := range headers {
req.Header.Set(key, value)
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}
q := req.URL.Query()
for key, value := range query {
q.Add(key, value)
}
req.URL.RawQuery = q.Encode()
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("invalid response code: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
var apiResponse APIResponse
err = json.Unmarshal(body, &apiResponse)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
tweet, err := FindTweetData(&apiResponse, tweetID)
if err != nil {
return nil, fmt.Errorf("failed to get tweet data: %w", err)
}
return tweet, nil
}

72
ext/twitter/models.go Normal file
View file

@ -0,0 +1,72 @@
package twitter
type APIResponse struct {
Data struct {
ThreadedConversationWithInjectionsV2 struct {
Instructions []struct {
Entries []struct {
EntryID string `json:"entryId"`
Content struct {
ItemContent struct {
TweetResults struct {
Result TweetResult `json:"result"`
} `json:"tweet_results"`
} `json:"itemContent"`
} `json:"content"`
} `json:"entries"`
} `json:"instructions"`
} `json:"threaded_conversation_with_injections_v2"`
} `json:"data"`
}
type TweetResult struct {
Tweet *Tweet `json:"tweet,omitempty"`
Legacy *Tweet `json:"legacy,omitempty"`
RestID string `json:"rest_id,omitempty"`
Core *Core `json:"core,omitempty"`
}
type Core struct {
UserResults struct {
Result struct {
Legacy *UserLegacy `json:"legacy,omitempty"`
} `json:"result"`
} `json:"user_results"`
}
type UserLegacy struct {
ScreenName string `json:"screen_name"`
Name string `json:"name"`
}
type Tweet struct {
FullText string `json:"full_text"`
ExtendedEntities *ExtendedEntities `json:"extended_entities,omitempty"`
Entities *ExtendedEntities `json:"entities,omitempty"`
CreatedAt string `json:"created_at"`
ID string `json:"id_str"`
}
type ExtendedEntities struct {
Media []MediaEntity `json:"media,omitempty"`
}
type MediaEntity struct {
Type string `json:"type"`
MediaURLHTTPS string `json:"media_url_https"`
ExpandedURL string `json:"expanded_url"`
URL string `json:"url"`
VideoInfo *VideoInfo `json:"video_info,omitempty"`
}
type VideoInfo struct {
DurationMillis int `json:"duration_millis"`
Variants []Variant `json:"variants"`
AspectRatio []int `json:"aspect_ratio"`
}
type Variant struct {
Bitrate int `json:"bitrate,omitempty"`
ContentType string `json:"content_type"`
URL string `json:"url"`
}

162
ext/twitter/util.go Normal file
View file

@ -0,0 +1,162 @@
package twitter
import (
"encoding/json"
"fmt"
"govd/enums"
"govd/models"
"govd/util"
"net/http"
"regexp"
"strconv"
"strings"
)
const authToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
var resolutionRegex = regexp.MustCompile(`(\d+)x(\d+)`)
func BuildAPIHeaders(cookies []*http.Cookie) map[string]string {
var csrfToken string
for _, cookie := range cookies {
if cookie.Name == "ct0" {
csrfToken = cookie.Value
break
}
}
if csrfToken == "" {
return nil
}
headers := map[string]string{
"authorization": fmt.Sprintf("Bearer %s", authToken),
"user-agent": util.ChromeUA,
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
"x-twitter-active-user": "yes",
}
if csrfToken != "" {
headers["x-csrf-token"] = csrfToken
}
return headers
}
func BuildAPIQuery(tweetID string) map[string]string {
variables := map[string]interface{}{
"focalTweetId": tweetID,
"includePromotedContent": true,
"with_rux_injections": false,
"withBirdwatchNotes": true,
"withCommunity": true,
"withDownvotePerspective": false,
"withQuickPromoteEligibilityTweetFields": true,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withSuperFollowsTweetFields": true,
"withSuperFollowsUserFields": true,
"withV2Timeline": true,
"withVoice": true,
}
features := map[string]interface{}{
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
"interactive_text_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"responsive_web_enhance_cards_enabled": true,
"responsive_web_graphql_timeline_navigation_enabled": false,
"responsive_web_text_conversations_enabled": false,
"responsive_web_uc_gql_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
"tweetypie_unmention_optimization_enabled": true,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": true,
"verified_phone_label_enabled": false,
"vibe_api_enabled": true,
}
variablesJSON, _ := json.Marshal(variables)
featuresJSON, _ := json.Marshal(features)
return map[string]string{
"variables": string(variablesJSON),
"features": string(featuresJSON),
}
}
func CleanCaption(caption string) string {
if caption == "" {
return ""
}
regex := regexp.MustCompile(`https?://t\.co/\S+`)
return strings.TrimSpace(regex.ReplaceAllString(caption, ""))
}
func ExtractVideoFormats(media *MediaEntity) ([]*models.MediaFormat, error) {
var formats []*models.MediaFormat
if media.VideoInfo == nil {
return formats, nil
}
duration := int64(media.VideoInfo.DurationMillis / 1000)
for _, variant := range media.VideoInfo.Variants {
if variant.ContentType == "video/mp4" {
width, height := extractResolution(variant.URL)
formats = append(formats, &models.MediaFormat{
Type: enums.MediaTypeVideo,
FormatID: fmt.Sprintf("mp4_%d", variant.Bitrate),
URL: []string{variant.URL},
VideoCodec: enums.MediaCodecAVC,
AudioCodec: enums.MediaCodecAAC,
Duration: duration,
Thumbnail: []string{media.MediaURLHTTPS},
Width: width,
Height: height,
Bitrate: int64(variant.Bitrate),
})
}
}
return formats, nil
}
func extractResolution(url string) (int64, int64) {
matches := resolutionRegex.FindStringSubmatch(url)
if len(matches) >= 3 {
width, _ := strconv.ParseInt(matches[1], 10, 64)
height, _ := strconv.ParseInt(matches[2], 10, 64)
return width, height
}
return 0, 0
}
func FindTweetData(resp *APIResponse, tweetID string) (*Tweet, error) {
instructions := resp.Data.ThreadedConversationWithInjectionsV2.Instructions
if len(instructions) == 0 {
return nil, fmt.Errorf("nessuna istruzione trovata nella risposta")
}
entries := instructions[0].Entries
entryID := fmt.Sprintf("tweet-%s", tweetID)
for _, entry := range entries {
if entry.EntryID == entryID {
result := entry.Content.ItemContent.TweetResults.Result
if result.Tweet != nil {
return result.Tweet, nil
}
if result.Legacy != nil {
return result.Legacy, nil
}
return nil, fmt.Errorf("struttura del tweet non valida")
}
}
return nil, fmt.Errorf("tweet non trovato nella risposta")
}

74
ext/util.go Normal file
View file

@ -0,0 +1,74 @@
package ext
import (
"fmt"
"govd/models"
)
var maxRedirects = 5
func CtxByURL(url string) (*models.DownloadContext, error) {
var redirectCount int
currentURL := url
for redirectCount <= maxRedirects {
for _, extractor := range List {
matches := extractor.URLPattern.FindStringSubmatch(currentURL)
if matches == nil {
continue
}
groupNames := extractor.URLPattern.SubexpNames()
if len(matches) == 0 {
continue
}
groups := make(map[string]string)
for i, name := range groupNames {
if name != "" {
groups[name] = matches[i]
}
}
groups["match"] = matches[0]
ctx := &models.DownloadContext{
MatchedContentID: groups["id"],
MatchedContentURL: groups["match"],
MatchedGroups: groups,
Extractor: extractor,
}
if !extractor.IsRedirect {
return ctx, nil
}
response, err := extractor.Run(ctx)
if err != nil {
return nil, err
}
if response.URL == "" {
return nil, fmt.Errorf("no URL found in response")
}
currentURL = response.URL
redirectCount++
break
}
if redirectCount > maxRedirects {
return nil, fmt.Errorf("exceeded maximum number of redirects (%d)", maxRedirects)
}
}
return nil, nil
}
func ByCodeName(codeName string) *models.Extractor {
for _, extractor := range List {
if extractor.CodeName == codeName {
return extractor
}
}
return nil
}

52
go.mod Normal file
View file

@ -0,0 +1,52 @@
module govd
go 1.24.0
require (
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.31
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/joho/godotenv v1.5.1
github.com/quic-go/quic-go v0.50.1
github.com/strukturag/libheif v1.19.7
github.com/tidwall/gjson v1.18.0
github.com/u2takey/ffmpeg-go v0.5.0
golang.org/x/image v0.26.0
gorm.io/gorm v1.25.12
)
require (
github.com/Eyevinn/dash-mpd v0.12.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/unki2aut/go-xsd-types v0.0.0-20200220223938-30e5405398f8 // indirect
)
require (
github.com/aki237/nscjar v0.0.0-20210417074043-bbb606196143
github.com/aws/aws-sdk-go v1.55.6 // indirect
github.com/etherlabsio/go-m3u8 v1.0.0
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/grafov/m3u8 v0.12.1
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pkg/errors v0.9.1
github.com/quic-go/qpack v0.5.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/u2takey/go-utils v0.3.1 // indirect
github.com/unki2aut/go-mpd v0.0.0-20250218132413-c6a2d2d492f4
go.uber.org/mock v0.5.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.22.0 // indirect
gorm.io/driver/mysql v1.5.7
)

162
go.sum Normal file
View file

@ -0,0 +1,162 @@
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/Eyevinn/dash-mpd v0.12.0 h1:fFNE9KPLqe4OG79fYyT/KalmFbQT2vG4Z01ppmEC4Aw=
github.com/Eyevinn/dash-mpd v0.12.0/go.mod h1:yym2itvB74evfJFDZB99p700LQddQFsN1YCbk9t6mAA=
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.31 h1:SIkzqC6Nv+znY4NGbWlJceWdns8QVmf9cwAYXd7Cg8k=
github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.31/go.mod h1:kL1v4iIjlalwm3gCYGvF4NLa3hs+aKEfRkNJvj4aoDU=
github.com/aki237/nscjar v0.0.0-20210417074043-bbb606196143 h1:PqRkQZW8lAlK2DnH9iSBfISmDxSChaoNJHwP0p7SD2Y=
github.com/aki237/nscjar v0.0.0-20210417074043-bbb606196143/go.mod h1:l0r3UsMujHR1bAYL7R0+6NXkHo/vIe+ja3xLZbUZNb8=
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/etherlabsio/go-m3u8 v1.0.0 h1:d3HJVr8wlbvJO20ksKEyvDYf4bcM7v8YV3W83fHswL0=
github.com/etherlabsio/go-m3u8 v1.0.0/go.mod h1:RzDiaXgaYnIEzZUmVUD/xMRFR7bY7U5JaCnp8XYLmXU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q=
github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/strukturag/libheif v1.19.7 h1:XMfSJvmnucTbiS6CSxxZmpx5XSPjdqkpA3wiL6+I2Iw=
github.com/strukturag/libheif v1.19.7/go.mod h1:E/PNRlmVtrtj9j2AvBZlrO4dsBDu6KfwDZn7X1Ce8Ks=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc=
github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys=
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs=
github.com/unki2aut/go-mpd v0.0.0-20250218132413-c6a2d2d492f4 h1:yPsATZRcBhrBQkxK9hsGo1cPHroobmw7Bptt+UJV0D8=
github.com/unki2aut/go-mpd v0.0.0-20250218132413-c6a2d2d492f4/go.mod h1:LITqXLCxxmcoHtOMgZh5NbcfS4RCrrADQXPVkYwF/cc=
github.com/unki2aut/go-xsd-types v0.0.0-20200220223938-30e5405398f8 h1:u0Bi6Mf8BKPQnxGJ7QubdMyhb0SJjnQU7kX0BA9eASk=
github.com/unki2aut/go-xsd-types v0.0.0-20200220223938-30e5405398f8/go.mod h1:uIeMfpmWIZ8SGp+fTfwDBWiiRn3aJm4b7rFSro9s++Q=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=

25
main.go Normal file
View file

@ -0,0 +1,25 @@
package main
import (
"govd/bot"
"govd/database"
"govd/util"
"log"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("error loading .env file")
}
ok := util.CheckFFmpeg()
if !ok {
log.Fatal("ffmpeg executable not found. please install it or add it to your PATH")
}
database.Start()
go bot.Start()
select {} // keep the main goroutine alive
}

9
models/ctx.go Normal file
View file

@ -0,0 +1,9 @@
package models
type DownloadContext struct {
MatchedContentID string
MatchedContentURL string
MatchedGroups map[string]string
GroupSettings *GroupSettings
Extractor *Extractor
}

14
models/download.go Normal file
View file

@ -0,0 +1,14 @@
package models
import "time"
type DownloadConfig struct {
ChunkSize int // size of each chunk in bytes
Concurrency int // maximum number of concurrent downloads
Timeout time.Duration // timeout for individual HTTP requests
DownloadDir string // directory to save downloaded files
RetryAttempts int // number of retry attempts per chunk
RetryDelay time.Duration // delay between retries
Remux bool // whether to remux the downloaded file with ffmpeg
ProgressUpdater func(float64) // optional function to report download progress
}

34
models/ext.go Normal file
View file

@ -0,0 +1,34 @@
package models
import (
"govd/enums"
"regexp"
)
type Extractor struct {
Name string
CodeName string
Type enums.ExtractorType
Category enums.ExtractorCategory
URLPattern *regexp.Regexp
IsDRM bool
IsRedirect bool
Run func(*DownloadContext) (*ExtractorResponse, error)
}
type ExtractorResponse struct {
MediaList []*Media
URL string // redirected URL
}
func (extractor *Extractor) NewMedia(
contentID string,
contentURL string,
) *Media {
return &Media{
ContentID: contentID,
ContentURL: contentURL,
ExtractorCodeName: extractor.CodeName,
}
}

506
models/media.go Normal file
View file

@ -0,0 +1,506 @@
package models
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"govd/enums"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/google/uuid"
"github.com/guregu/null/v6/zero"
"gorm.io/gorm"
)
type Media struct {
ID uint `json:"-"`
ContentID string `gorm:"not null;index" json:"content_id"`
ContentURL string `gorm:"not null" json:"content_url"`
ExtractorCodeName string `gorm:"not null;index" json:"extractor_code_name"`
Caption zero.String `json:"caption"`
NSFW bool `gorm:"default:false" json:"nsfw"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Format *MediaFormat `json:"-"`
Formats []*MediaFormat `gorm:"-" json:"formats"`
}
type MediaFormat struct {
ID uint `json:"-"`
MediaID uint `gorm:"index:idx_media_format,priority:1;not null" json:"-"`
Type enums.MediaType `gorm:"not null;index:idx_media_type" json:"type"`
FormatID string `gorm:"not null;index" json:"format_id"`
FileID string `gorm:"not null;index" json:"-"`
VideoCodec enums.MediaCodec `json:"video_codec"`
AudioCodec enums.MediaCodec `json:"audio_codec"`
Duration int64 `json:"duration"`
Width int64 `json:"width"`
Height int64 `json:"height"`
Bitrate int64 `json:"bitrate"`
Title string `json:"title"`
Artist string `json:"artist"`
IsDefault bool `gorm:"default:false;index" json:"is_default"`
Segments []string `gorm:"-" json:"segments"`
FileSize int64 `json:"-"`
Plugins []Plugin `gorm:"-" json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// api use only, not stored in database
URL []string `gorm:"-" json:"url"`
Thumbnail []string `gorm:"-" json:"thumbnail"`
Media *Media `gorm:"foreignKey:MediaID" json:"-"`
}
type DownloadedMedia struct {
FilePath string
ThumbnailFilePath string
Media *Media
Index int
}
func (media *Media) GetFormat(formatID string) *MediaFormat {
for _, format := range media.Formats {
if format.FormatID == formatID {
return format
}
}
return nil
}
func (media *Media) GetDefaultFormat() *MediaFormat {
format := media.GetDefaultVideoFormat()
if format != nil {
return format
}
format = media.GetDefaultAudioFormat()
if format != nil {
return format
}
format = media.GetDefaultPhotoFormat()
if format != nil {
return format
}
return nil
}
func (media *Media) GetDefaultVideoFormat() *MediaFormat {
filtered := filterFormats(media.Formats, func(format *MediaFormat) bool {
return format.VideoCodec == enums.MediaCodecAVC
})
if len(filtered) == 0 {
filtered = filterFormats(media.Formats, func(format *MediaFormat) bool {
return format.VideoCodec != ""
})
}
if len(filtered) == 0 {
return nil
}
bestFormat := filtered[0]
for _, format := range filtered {
if format.Bitrate > bestFormat.Bitrate {
bestFormat = format
}
}
bestFormat.IsDefault = true
return bestFormat
}
func (media *Media) GetDefaultAudioFormat() *MediaFormat {
filtered := filterFormats(media.Formats, func(format *MediaFormat) bool {
return format.VideoCodec == "" &&
(format.AudioCodec == enums.MediaCodecAAC ||
format.AudioCodec == enums.MediaCodecMP3)
})
if len(filtered) == 0 {
filtered = filterFormats(media.Formats, func(format *MediaFormat) bool {
return format.VideoCodec == "" && format.AudioCodec != ""
})
}
if len(filtered) == 0 {
return nil
}
bestFormat := filtered[0]
for _, format := range filtered {
if format.Bitrate > bestFormat.Bitrate {
bestFormat = format
}
}
bestFormat.IsDefault = true
return bestFormat
}
func (media *Media) GetDefaultPhotoFormat() *MediaFormat {
filtered := filterFormats(media.Formats, func(format *MediaFormat) bool {
return format.Type == enums.MediaTypePhoto
})
if len(filtered) == 0 {
return nil
}
filtered[0].IsDefault = true
return filtered[0]
}
func (media *Media) GetAudioFromVideoFormat() *MediaFormat {
videoFormat := media.GetDefaultVideoFormat()
if videoFormat == nil {
return nil
}
return &MediaFormat{
Type: enums.MediaTypeAudio,
FormatID: "AudioFromVideo",
URL: videoFormat.URL,
AudioCodec: enums.MediaCodecAAC,
Thumbnail: videoFormat.Thumbnail,
Duration: videoFormat.Duration,
Title: videoFormat.Title,
Artist: videoFormat.Artist,
}
}
func (media *Media) SetCaption(caption string) {
if len(caption) == 0 {
return
}
media.Caption = zero.StringFrom(caption)
}
func (media *Media) AddFormat(fmt *MediaFormat) {
media.Formats = append(media.Formats, fmt)
}
func (media *Media) GetSortedFormats() []*MediaFormat {
// group by video format (codec, width, height)
groupedVideos := make(map[[3]int64]*MediaFormat)
for _, format := range media.Formats {
if format.Type == enums.MediaTypeVideo {
key := [3]int64{
getCodecPriority(format.VideoCodec),
format.Width,
format.Height,
}
existing, ok := groupedVideos[key]
if !ok || format.Bitrate > existing.Bitrate {
groupedVideos[key] = format
}
}
}
// group by audio format (codec, bitrate)
groupedAudios := make(map[[2]int64]*MediaFormat)
for _, format := range media.Formats {
if format.Type == enums.MediaTypeAudio {
key := [2]int64{
getCodecPriority(format.AudioCodec),
format.Bitrate,
}
_, exists := groupedAudios[key]
if !exists {
groupedAudios[key] = format
}
}
}
// combine the best video and audio into a final list
var finalSortedList []*MediaFormat
for _, best := range groupedVideos {
finalSortedList = append(finalSortedList, best)
}
for _, best := range groupedAudios {
finalSortedList = append(finalSortedList, best)
}
for _, format := range media.Formats {
if format.Type != enums.MediaTypeVideo && format.Type != enums.MediaTypeAudio {
finalSortedList = append(finalSortedList, format) // for non-video and non-audio formats
}
}
// sort the final list
sort.Slice(finalSortedList, func(i, j int) bool {
a, b := finalSortedList[i], finalSortedList[j]
// compare by type priority (video, audio, photo, etc.)
if cmp := getTypePriority(a.Type) - getTypePriority(b.Type); cmp != 0 {
return cmp < 0
}
// compare by codec priority (for both video and audio)
if a.Type == enums.MediaTypeVideo {
if cmp := getCodecPriority(a.VideoCodec) - getCodecPriority(b.VideoCodec); cmp != 0 {
return cmp < 0
}
} else if a.Type == enums.MediaTypeAudio {
if cmp := getCodecPriority(a.AudioCodec) - getCodecPriority(b.AudioCodec); cmp != 0 {
return cmp < 0
}
}
// compare by width for videos
if cmp := a.Width - b.Width; cmp != 0 {
return cmp < 0
}
// compare by height for videos
if cmp := a.Height - b.Height; cmp != 0 {
return cmp < 0
}
// compare by bitrate (lower bitrate first)
return a.Bitrate-b.Bitrate < 0
})
return finalSortedList
}
func filterFormats(
formats []*MediaFormat,
condition func(*MediaFormat) bool,
) []*MediaFormat {
var filtered []*MediaFormat
for _, format := range formats {
if condition(format) {
filtered = append(filtered, format)
}
}
return filtered
}
func getCodecPriority(codec enums.MediaCodec) int64 {
codecPriority := map[enums.MediaCodec]int64{
enums.MediaCodecAVC: 1,
enums.MediaCodecHEVC: 2,
enums.MediaCodecMP3: 3,
enums.MediaCodecAAC: 4,
}
return codecPriority[codec]
}
func getTypePriority(mediaType enums.MediaType) int64 {
typePriority := map[enums.MediaType]int64{
enums.MediaTypeVideo: 1,
enums.MediaTypeAudio: 2,
enums.MediaTypePhoto: 3,
}
return typePriority[mediaType]
}
// getFormatInfo returns the file extension and the InputMedia type.
func (format *MediaFormat) GetFormatInfo() (string, string) {
if format.Type == enums.MediaTypePhoto {
return "jpeg", "photo"
}
videoCodec := format.VideoCodec
audioCodec := format.AudioCodec
switch {
case videoCodec == enums.MediaCodecAVC && audioCodec == enums.MediaCodecAAC:
return "mp4", "video"
case videoCodec == enums.MediaCodecAVC && audioCodec == enums.MediaCodecMP3:
return "mp4", "video"
case videoCodec == enums.MediaCodecHEVC && audioCodec == enums.MediaCodecAAC:
return "mp4", "document"
case videoCodec == enums.MediaCodecHEVC && audioCodec == enums.MediaCodecMP3:
return "mp4", "document"
case videoCodec == enums.MediaCodecAV1 && audioCodec == enums.MediaCodecOpus:
return "webm", "document"
case videoCodec == enums.MediaCodecAV1 && audioCodec == enums.MediaCodecFLAC:
return "webm", "document"
case videoCodec == enums.MediaCodecVP9 && audioCodec == enums.MediaCodecOpus:
return "webm", "document"
case videoCodec == enums.MediaCodecVP9 && audioCodec == enums.MediaCodecFLAC:
return "webm", "document"
case videoCodec == enums.MediaCodecAVC && audioCodec == "":
return "mp4", "video"
case videoCodec == enums.MediaCodecHEVC && audioCodec == "":
return "mp4", "document"
case videoCodec == enums.MediaCodecAV1 && audioCodec == "":
return "webm", "document"
case videoCodec == enums.MediaCodecVP9 && audioCodec == "":
return "webm", "document"
case videoCodec == enums.MediaCodecVP8 && audioCodec == "":
return "webm", "document"
case videoCodec == enums.MediaCodecWebP && audioCodec == "":
return "webp", "video"
case videoCodec == "" && audioCodec == enums.MediaCodecMP3:
return "mp3", "audio"
case videoCodec == "" && audioCodec == enums.MediaCodecAAC:
return "m4a", "audio"
case videoCodec == "" && audioCodec == enums.MediaCodecOpus:
return "webm", "document"
case videoCodec == "" && audioCodec == enums.MediaCodecFLAC:
return "flac", "document"
case videoCodec == "" && audioCodec == enums.MediaCodecVorbis:
return "oga", "document"
default:
return "webm", "document"
}
}
func (format *MediaFormat) GetInputMedia(
filePath string,
thumbnailFilePath string,
messageCaption string,
) (gotgbot.InputMedia, error) {
if format.FileID != "" {
return format.GetInputMediaWithFileID(messageCaption)
}
_, inputMediaType := format.GetFormatInfo()
fileObj, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
fileInputMedia := gotgbot.InputFileByReader(
filepath.Base(filePath),
fileObj,
)
var thumbnailFileInputMedia gotgbot.InputFile
if thumbnailFilePath != "" {
thumbnailFileObj, err := os.Open(thumbnailFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
thumbnailFileInputMedia = gotgbot.InputFileByReader(
filepath.Base(thumbnailFilePath),
thumbnailFileObj,
)
}
if inputMediaType == "video" {
return &gotgbot.InputMediaVideo{
Media: fileInputMedia,
Thumbnail: thumbnailFileInputMedia,
Width: format.Width,
Height: format.Height,
Duration: format.Duration,
Caption: messageCaption,
SupportsStreaming: true,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "audio" {
return &gotgbot.InputMediaAudio{
Media: fileInputMedia,
Thumbnail: thumbnailFileInputMedia,
Duration: format.Duration,
Performer: format.Artist,
Title: format.Title,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "photo" {
return &gotgbot.InputMediaPhoto{
Media: fileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "document" {
return &gotgbot.InputMediaDocument{
Media: fileInputMedia,
Thumbnail: thumbnailFileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
return nil, fmt.Errorf("unknown input type: %s", inputMediaType)
}
func (format *MediaFormat) GetInputMediaWithFileID(
messageCaption string,
) (gotgbot.InputMedia, error) {
_, inputMediaType := format.GetFormatInfo()
fileInputMedia := gotgbot.InputFileByID(format.FileID)
if inputMediaType == "video" {
return &gotgbot.InputMediaVideo{
Media: fileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "audio" {
return &gotgbot.InputMediaAudio{
Media: fileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "photo" {
return &gotgbot.InputMediaPhoto{
Media: fileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
if inputMediaType == "document" {
return &gotgbot.InputMediaDocument{
Media: fileInputMedia,
Caption: messageCaption,
ParseMode: "HTML",
}, nil
}
return nil, fmt.Errorf("unknown input type: %s", inputMediaType)
}
func (format *MediaFormat) GetFileName() string {
extension, _ := format.GetFormatInfo()
if format.Type == enums.MediaTypeAudio && format.Title != "" && format.Artist != "" {
return fmt.Sprintf("%s - %s.%s", format.Artist, format.Title, extension)
} else {
name := uuid.New().String()
name = strings.ReplaceAll(name, "-", "")
return fmt.Sprintf("%s.%s", name, extension)
}
}
func (media *Media) HasVideo() bool {
for _, format := range media.Formats {
if format.Type == enums.MediaTypeVideo {
return true
}
}
return false
}
func (media *Media) HasAudio() bool {
for _, format := range media.Formats {
if format.Type == enums.MediaTypeAudio {
return true
}
}
return false
}
func (media *Media) HasPhoto() bool {
for _, format := range media.Formats {
if format.Type == enums.MediaTypePhoto {
return true
}
}
return false
}
func (media *Media) SupportsAudio() bool {
for _, format := range media.Formats {
if format.AudioCodec != "" {
return true
}
}
return false
}
func (media *Media) SupportsAudioFromVideo() bool {
return !media.HasAudio() && media.HasVideo() && media.SupportsAudio()
}

11
models/misc.go Normal file
View file

@ -0,0 +1,11 @@
package models
type SendMediaFormatsOptions struct {
IsStored bool
Caption string
}
type Chunk struct {
Data []byte
Idx int
}

3
models/plugin.go Normal file
View file

@ -0,0 +1,3 @@
package models
type Plugin = func(*DownloadedMedia) error

12
models/settings.go Normal file
View file

@ -0,0 +1,12 @@
package models
import "gorm.io/gorm"
type GroupSettings struct {
gorm.Model
ChatID int64 `gorm:"primaryKey"`
NSFW *bool `gorm:"default:false"`
Captions *bool `gorm:"default:false"`
MediaGroupLimit int `gorm:"default:10"`
}

14
models/user.go Normal file
View file

@ -0,0 +1,14 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
gorm.Model
UserID int64 `gorm:"primaryKey"`
LastUsed time.Time `gorm:"autoCreateTime"`
}

7
plugins/main.go Normal file
View file

@ -0,0 +1,7 @@
package plugins
import "govd/models"
var List = []models.Plugin{
MergeAudio,
}

40
plugins/merge_audio.go Normal file
View file

@ -0,0 +1,40 @@
package plugins
import (
"context"
"fmt"
"govd/models"
"govd/util"
"govd/util/av"
"github.com/pkg/errors"
)
func MergeAudio(media *models.DownloadedMedia) error {
audioFormat := media.Media.GetDefaultAudioFormat()
if audioFormat == nil {
return errors.New("no audio format found")
}
// download the audio file
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
audioFile, err := util.DownloadFile(
ctx, audioFormat.URL,
audioFormat.GetFileName(), nil,
)
if err != nil {
return fmt.Errorf("failed to download audio file: %w", err)
}
err = av.MergeVideoWithAudio(
media.FilePath,
audioFile,
)
if err != nil {
return fmt.Errorf("failed to merge video with audio: %w", err)
}
return nil
}

23
util/av/audio.go Normal file
View file

@ -0,0 +1,23 @@
package av
import (
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func AudioFromVideo(videoPath string, audioPath string) error {
err := ffmpeg.
Input(videoPath).
Output(audioPath, ffmpeg.KwArgs{
"map": "a",
"vn": nil,
"f": "mp3",
"ab": "128k",
}).
Silent(true).
OverWriteOutput().
Run()
if err != nil {
return err
}
return nil
}

46
util/av/merge_audio.go Normal file
View file

@ -0,0 +1,46 @@
package av
import (
"fmt"
"os"
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func MergeVideoWithAudio(
videoFile string,
audioFile string,
) error {
tempFileName := videoFile + ".temp"
outputFile := videoFile
err := os.Rename(videoFile, tempFileName)
if err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
defer os.Remove(tempFileName)
defer os.Remove(audioFile)
videoStream := ffmpeg.Input(tempFileName)
audioStream := ffmpeg.Input(audioFile)
err = ffmpeg.Output(
[]*ffmpeg.Stream{videoStream, audioStream},
outputFile,
ffmpeg.KwArgs{
"map": []string{"0:v:0", "1:a:0"},
"movflags": "+faststart",
"c:v": "copy",
"c:a": "copy",
}).
Silent(true).
OverWriteOutput().
Run()
if err != nil {
return fmt.Errorf("failed to merge files: %w", err)
}
return nil
}

35
util/av/remux.go Normal file
View file

@ -0,0 +1,35 @@
package av
import (
"fmt"
"os"
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func RemuxFile(
inputFile string,
) error {
tempFileName := inputFile + ".temp"
outputFile := inputFile
err := os.Rename(inputFile, tempFileName)
if err != nil {
return fmt.Errorf("failed to rename file: %v", err)
}
err = ffmpeg.
Input(tempFileName).
Output(outputFile, ffmpeg.KwArgs{
"c": "copy",
}).
Silent(true).
OverWriteOutput().
Run()
if err != nil {
return fmt.Errorf("failed to remux file: %v", err)
}
err = os.Remove(tempFileName)
if err != nil {
return fmt.Errorf("failed to remove temp file: %v", err)
}
return nil
}

27
util/av/thumbnail.go Normal file
View file

@ -0,0 +1,27 @@
package av
import (
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func ExtractVideoThumbnail(
videoPath string,
thumbnailPath string,
) error {
err := ffmpeg.
Input(videoPath).
Output(thumbnailPath, ffmpeg.KwArgs{
"vframes": 1,
"f": "image2",
"ss": "00:00:01",
"c:v": "mjpeg",
"q:v": 10, // not sure
}).
Silent(true).
OverWriteOutput().
Run()
if err != nil {
return err
}
return nil
}

18
util/av/videoinfo.go Normal file
View file

@ -0,0 +1,18 @@
package av
import (
"github.com/tidwall/gjson"
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func GetVideoInfo(filePath string) (int64, int64, int64) {
probeData, err := ffmpeg.Probe(filePath)
if err != nil {
return 0, 0, 0
}
duration := gjson.Get(probeData, "format.duration").Int()
width := gjson.Get(probeData, "streams.0.width").Int()
height := gjson.Get(probeData, "streams.0.height").Int()
return duration, width, height
}

5
util/consts.go Normal file
View file

@ -0,0 +1,5 @@
package util
const (
ChromeUA = "Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.181 Mobile Safari/537.36"
)

512
util/download.go Normal file
View file

@ -0,0 +1,512 @@
package util
import (
"bytes"
"context"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"govd/models"
"govd/util/av"
)
func DefaultConfig() *models.DownloadConfig {
return &models.DownloadConfig{
ChunkSize: 10 * 1024 * 1024, // 10MB
Concurrency: 4,
Timeout: 30 * time.Second,
DownloadDir: "downloads",
RetryAttempts: 3,
RetryDelay: 2 * time.Second,
Remux: true,
}
}
func DownloadFile(
ctx context.Context,
URLList []string,
fileName string,
config *models.DownloadConfig,
) (string, error) {
if config == nil {
config = DefaultConfig()
}
var errs []error
for _, fileURL := range URLList {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
// create the download directory if it doesn't exist
if err := ensureDownloadDir(config.DownloadDir); err != nil {
return "", err
}
filePath := filepath.Join(config.DownloadDir, fileName)
err := runChunkedDownload(ctx, fileURL, filePath, config)
if err != nil {
errs = append(errs, err)
continue
}
if config.Remux {
err := av.RemuxFile(filePath)
if err != nil {
return "", fmt.Errorf("remuxing failed: %w", err)
}
}
return filePath, nil
}
}
return "", fmt.Errorf("%w: %v", ErrDownloadFailed, errs)
}
func DownloadFileWithSegments(
ctx context.Context,
segmentURLs []string,
fileName string,
config *models.DownloadConfig,
) (string, error) {
if config == nil {
config = DefaultConfig()
}
if err := ensureDownloadDir(config.DownloadDir); err != nil {
return "", err
}
tempDir := filepath.Join(config.DownloadDir, "segments_"+time.Now().Format("20060102_150405"))
if err := os.MkdirAll(tempDir, 0755); err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}
downloadedFiles, err := DownloadSegments(ctx, segmentURLs, config)
if err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to download segments: %w", err)
}
mergedFilePath, err := MergeSegmentFiles(ctx, downloadedFiles, fileName, config)
if err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to merge segments: %w", err)
}
if err := os.RemoveAll(tempDir); err != nil {
return "", fmt.Errorf("failed to remove temporary directory: %w", err)
}
return mergedFilePath, nil
}
func DownloadFileInMemory(
ctx context.Context,
URLList []string,
config *models.DownloadConfig,
) (*bytes.Reader, error) {
if config == nil {
config = DefaultConfig()
}
var errs []error
for _, fileURL := range URLList {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
data, err := downloadInMemory(ctx, fileURL, config.Timeout)
if err != nil {
errs = append(errs, err)
continue
}
return bytes.NewReader(data), nil
}
}
return nil, fmt.Errorf("%w: %v", ErrDownloadFailed, errs)
}
func downloadInMemory(ctx context.Context, fileURL string, timeout time.Duration) ([]byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
session := GetHTTPSession()
resp, err := session.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func ensureDownloadDir(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create downloads directory: %w", err)
}
}
return nil
}
func runChunkedDownload(
ctx context.Context,
fileURL string,
filePath string,
config *models.DownloadConfig,
) error {
fileSize, err := getFileSize(ctx, fileURL, config.Timeout)
if err != nil {
return err
}
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
// pre-allocate file size if possible
if fileSize > 0 {
if err := file.Truncate(int64(fileSize)); err != nil {
return fmt.Errorf("failed to allocate file space: %w", err)
}
}
chunks := createChunks(fileSize, config.ChunkSize)
semaphore := make(chan struct{}, config.Concurrency)
var wg sync.WaitGroup
errChan := make(chan error, 1)
var downloadErr error
var errOnce sync.Once
var completedChunks int64
var completedBytes int64
var progressMutex sync.Mutex
downloadCtx, cancelDownload := context.WithCancel(ctx)
defer cancelDownload()
for idx, chunk := range chunks {
wg.Add(1)
go func(idx int, chunk [2]int) {
defer wg.Done()
// respect concurrency limit
select {
case semaphore <- struct{}{}:
defer func() { <-semaphore }()
case <-downloadCtx.Done():
return
}
chunkData, err := downloadChunkWithRetry(downloadCtx, fileURL, chunk, config)
if err != nil {
errOnce.Do(func() {
downloadErr = fmt.Errorf("chunk %d: %w", idx, err)
cancelDownload() // cancel all other downloads
errChan <- downloadErr
})
return
}
if err := writeChunkToFile(file, chunkData, chunk[0]); err != nil {
errOnce.Do(func() {
downloadErr = fmt.Errorf("failed to write chunk %d: %w", idx, err)
cancelDownload()
errChan <- downloadErr
})
return
}
// update progress
chunkSize := chunk[1] - chunk[0] + 1
progressMutex.Lock()
completedChunks++
completedBytes += int64(chunkSize)
progress := float64(completedBytes) / float64(fileSize)
progressMutex.Unlock()
// report progress if handler exists
if config.ProgressUpdater != nil {
config.ProgressUpdater(progress)
}
}(idx, chunk)
}
go func() {
wg.Wait()
close(errChan)
}()
select {
case err := <-errChan:
if err != nil {
// clean up partial download
os.Remove(filePath)
return err
}
case <-ctx.Done():
cancelDownload()
os.Remove(filePath)
return ctx.Err()
}
return nil
}
func getFileSize(ctx context.Context, fileURL string, timeout time.Duration) (int, error) {
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, fileURL, nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
session := GetHTTPSession()
resp, err := session.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to get file size: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to get file info: status code %d", resp.StatusCode)
}
return int(resp.ContentLength), nil
}
func downloadChunkWithRetry(
ctx context.Context,
fileURL string,
chunk [2]int,
config *models.DownloadConfig,
) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= config.RetryAttempts; attempt++ {
if attempt > 0 {
// wait before retry
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(config.RetryDelay):
}
}
data, err := downloadChunk(ctx, fileURL, chunk, config.Timeout)
if err == nil {
return data, nil
}
lastErr = err
}
return nil, fmt.Errorf("all %d attempts failed: %w", config.RetryAttempts+1, lastErr)
}
func downloadChunk(
ctx context.Context,
fileURL string,
chunk [2]int,
timeout time.Duration,
) ([]byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", chunk[0], chunk[1]))
session := GetHTTPSession()
resp, err := session.Do(req)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func writeChunkToFile(file *os.File, data []byte, offset int) error {
_, err := file.WriteAt(data, int64(offset))
return err
}
func createChunks(fileSize int, chunkSize int) [][2]int {
if fileSize <= 0 {
return [][2]int{{0, 0}}
}
numChunks := int(math.Ceil(float64(fileSize) / float64(chunkSize)))
chunks := make([][2]int, numChunks)
for i := 0; i < numChunks; i++ {
start := i * chunkSize
end := start + chunkSize - 1
if end >= fileSize {
end = fileSize - 1
}
chunks[i] = [2]int{start, end}
}
return chunks
}
func DownloadSegments(
ctx context.Context,
segmentURLs []string,
config *models.DownloadConfig,
) ([]string, error) {
if config == nil {
config = DefaultConfig()
}
tempDir := filepath.Join(config.DownloadDir, "segments_"+time.Now().Format("20060102_150405"))
if err := os.MkdirAll(tempDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
}
semaphore := make(chan struct{}, config.Concurrency)
var wg sync.WaitGroup
errChan := make(chan error, len(segmentURLs))
downloadedFiles := make([]string, len(segmentURLs))
for i, segmentURL := range segmentURLs {
wg.Add(1)
go func(idx int, url string) {
defer wg.Done()
// acquire semaphore slot
semaphore <- struct{}{}
defer func() { <-semaphore }()
segmentFileName := fmt.Sprintf("segment_%05d", idx)
segmentPath := filepath.Join(tempDir, segmentFileName)
_, err := DownloadFile(ctx, []string{url}, segmentFileName, &models.DownloadConfig{
ChunkSize: config.ChunkSize,
Concurrency: 3, // segments are typically small
Timeout: config.Timeout,
DownloadDir: tempDir,
RetryAttempts: config.RetryAttempts,
RetryDelay: config.RetryDelay,
Remux: false, // don't remux individual segments
ProgressUpdater: nil, // no progress updates for individual segments
})
if err != nil {
errChan <- fmt.Errorf("failed to download segment %d: %w", idx, err)
return
}
downloadedFiles[idx] = segmentPath
}(i, segmentURL)
}
go func() {
wg.Wait()
close(errChan)
}()
for err := range errChan {
if err != nil {
os.RemoveAll(tempDir)
return nil, err
}
}
return downloadedFiles, nil
}
func MergeSegmentFiles(
ctx context.Context,
segmentPaths []string,
outputFileName string,
config *models.DownloadConfig,
) (string, error) {
if config == nil {
config = DefaultConfig()
}
if err := ensureDownloadDir(config.DownloadDir); err != nil {
return "", err
}
outputPath := filepath.Join(config.DownloadDir, outputFileName)
outputFile, err := os.Create(outputPath)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer outputFile.Close()
var totalBytes int64
var processedBytes int64
if config.ProgressUpdater != nil {
for _, segmentPath := range segmentPaths {
fileInfo, err := os.Stat(segmentPath)
if err == nil {
totalBytes += fileInfo.Size()
}
}
}
for i, segmentPath := range segmentPaths {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
segmentFile, err := os.Open(segmentPath)
if err != nil {
return "", fmt.Errorf("failed to open segment %d: %w", i, err)
}
written, err := io.Copy(outputFile, segmentFile)
segmentFile.Close()
if err != nil {
return "", fmt.Errorf("failed to copy segment %d: %w", i, err)
}
if config.ProgressUpdater != nil && totalBytes > 0 {
processedBytes += written
progress := float64(processedBytes) / float64(totalBytes)
config.ProgressUpdater(progress)
}
}
}
if config.Remux {
err := av.RemuxFile(outputPath)
if err != nil {
return "", fmt.Errorf("remuxing failed: %w", err)
}
}
return outputPath, nil
}

23
util/errors.go Normal file
View file

@ -0,0 +1,23 @@
package util
type Error struct {
Message string
}
func (err *Error) Error() string {
return err.Message
}
var (
ErrUnavailable = &Error{Message: "this content is unavailable"}
ErrNotImplemented = &Error{Message: "this feature is not implemented"}
ErrTimeout = &Error{Message: "timeout error when downloading. try again"}
ErrUnknownRIFF = &Error{Message: "uknown RIFF format"}
ErrUnsupportedImageFormat = &Error{Message: "unsupported image format"}
ErrFileTooShort = &Error{Message: "file too short"}
ErrDownloadFailed = &Error{Message: "download failed"}
ErrUnsupportedExtractorType = &Error{Message: "unsupported extractor type"}
ErrMediaGroupLimitExceeded = &Error{Message: "media group limit exceeded for this group. try changing /settings"}
ErrNSFWNotAllowed = &Error{Message: "this content is marked as nsfw and can't be downloaded in this group. try changing /settings or use me privately"}
ErrInlineMediaGroup = &Error{Message: "you can't download media groups in inline mode. try using me in a private chat"}
)

14
util/http.go Normal file
View file

@ -0,0 +1,14 @@
package util
import (
"net/http"
"time"
)
var httpSession = &http.Client{
Timeout: 20 * time.Second,
}
func GetHTTPSession() *http.Client {
return httpSession
}

126
util/img.go Normal file
View file

@ -0,0 +1,126 @@
package util
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"io"
"os"
_ "image/gif"
_ "image/png"
_ "github.com/strukturag/libheif/go/heif"
_ "golang.org/x/image/webp"
)
var (
jpegMagic = []byte{0xFF, 0xD8, 0xFF}
pngMagic = []byte{0x89, 0x50, 0x4E, 0x47}
gifMagic = []byte{0x47, 0x49, 0x46}
riffMagic = []byte{0x52, 0x49, 0x46, 0x46}
webpMagic = []byte{0x57, 0x45, 0x42, 0x50}
)
func ImgToJPEG(file io.ReadSeeker, outputPath string) error {
format, err := DetectImageFormat(file)
if err != nil {
return fmt.Errorf("failed to detect image format: %w", err)
}
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outputFile.Close()
if format == "jpeg" {
if _, err = file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to reset file position: %w", err)
}
if _, err = io.Copy(outputFile, file); err != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to copy image: %w", err)
}
return nil
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to reset file position: %w", err)
}
img, _, err := image.Decode(file)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
}
err = jpeg.Encode(outputFile, img, nil)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to encode image: %w", err)
}
return nil
}
func DetectImageFormat(file io.ReadSeeker) (string, error) {
header := make([]byte, 12)
_, err := file.Read(header)
if err != nil {
return "", fmt.Errorf("failed to read file header: %w", err)
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return "", fmt.Errorf("failed to reset file position: %w", err)
}
if len(header) < 12 {
return "", ErrFileTooShort
}
if bytes.HasPrefix(header, jpegMagic) {
return "jpeg", nil
}
if bytes.HasPrefix(header, pngMagic) {
return "png", nil
}
if bytes.HasPrefix(header, gifMagic) {
return "gif", nil
}
if isHEIF(header) {
return "heif", nil
}
if bytes.HasPrefix(header, riffMagic) {
if bytes.Equal(header[8:12], webpMagic) {
return "webp", nil
}
return "", ErrUnknownRIFF
}
return "", ErrUnsupportedImageFormat
}
func isHEIF(header []byte) bool {
if len(header) < 12 {
return false
}
isHeifHeader := header[0] == 0x00 && header[1] == 0x00 &&
header[2] == 0x00 && (header[3] == 0x18 || header[3] == 0x1C) &&
bytes.Equal(header[4:8], []byte("ftyp"))
if !isHeifHeader {
return false
}
heifBrands := []string{"heic", "heix", "mif1", "msf1"}
brand := string(header[8:12])
for _, b := range heifBrands {
if brand == b {
return true
}
}
return false
}

112
util/misc.go Normal file
View file

@ -0,0 +1,112 @@
package util
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/aki237/nscjar"
)
func GetLocationURL(
url string,
userAgent string,
) (string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
if userAgent == "" {
userAgent = ChromeUA
}
req.Header.Set("User-Agent", ChromeUA)
session := GetHTTPSession()
resp, err := session.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
return resp.Request.URL.String(), nil
}
func IsUserAdmin(
bot *gotgbot.Bot,
chatID int64,
userID int64,
) bool {
chatMember, err := bot.GetChatMember(chatID, userID, nil)
if err != nil {
return false
}
if chatMember == nil {
return false
}
status := chatMember.GetStatus()
switch status {
case "creator":
return true
case "administrator":
if chatMember.MergeChatMember().CanChangeInfo {
return true
}
return false
}
return false
}
func EscapeCaption(str string) string {
// we wont use html.EscapeString
// cuz it will escape all the characters
// and we only need to escape < and >
chars := map[string]string{
"<": "&lt;",
">": "&gt;",
}
for k, v := range chars {
str = strings.ReplaceAll(str, k, v)
}
return str
}
func GetLastError(err error) error {
var lastErr error = err
for {
unwrapped := errors.Unwrap(lastErr)
if unwrapped == nil {
break
}
lastErr = unwrapped
}
return lastErr
}
func ParseCookieFile(fileName string) ([]*http.Cookie, error) {
cookiePath := filepath.Join("cookies", fileName)
cookieFile, err := os.Open(cookiePath)
if err != nil {
return nil, fmt.Errorf("failed to open cookie file: %w", err)
}
defer cookieFile.Close()
var parser nscjar.Parser
cookies, err := parser.Unmarshal(cookieFile)
if err != nil {
return nil, fmt.Errorf("failed to parse cookie file: %w", err)
}
return cookies, nil
}
func FixURL(url string) string {
return strings.ReplaceAll(url, "&amp;", "&")
}
func CheckFFmpeg() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}

182
util/parser/m3u8.go Normal file
View file

@ -0,0 +1,182 @@
package parser
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/pkg/errors"
"govd/enums"
"govd/models"
"github.com/grafov/m3u8"
)
var httpClient = &http.Client{
Timeout: 30 * time.Second,
}
func ParseM3U8Content(
content []byte,
baseURL string,
) ([]*models.MediaFormat, error) {
baseURLObj, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base url: %w", err)
}
buf := bytes.NewBuffer(content)
playlist, listType, err := m3u8.DecodeFrom(buf, true)
if err != nil {
return nil, fmt.Errorf("failed parsing m3u8: %w", err)
}
var formats []*models.MediaFormat
if listType == m3u8.MASTER {
masterpl := playlist.(*m3u8.MasterPlaylist)
for _, variant := range masterpl.Variants {
if variant == nil || variant.URI == "" {
continue
}
width, height := int64(0), int64(0)
if variant.Resolution != "" {
var w, h int
if _, err := fmt.Sscanf(variant.Resolution, "%dx%d", &w, &h); err == nil {
width, height = int64(w), int64(h)
}
}
format := &models.MediaFormat{
Type: enums.MediaTypeVideo,
FormatID: fmt.Sprintf("hls-%d", variant.Bandwidth/1000),
VideoCodec: getCodecFromCodecs(variant.Codecs),
AudioCodec: getAudioCodecFromCodecs(variant.Codecs),
Bitrate: int64(variant.Bandwidth),
Width: width,
Height: height,
}
variantURL := resolveURL(baseURLObj, variant.URI)
format.URL = []string{variantURL}
variantContent, err := fetchContent(variantURL)
if err == nil {
variantFormats, err := ParseM3U8Content(variantContent, variantURL)
if err == nil && len(variantFormats) > 0 {
format.Segments = variantFormats[0].Segments
if variantFormats[0].Duration > 0 {
format.Duration = variantFormats[0].Duration
}
}
}
formats = append(formats, format)
}
return formats, nil
}
if listType == m3u8.MEDIA {
mediapl := playlist.(*m3u8.MediaPlaylist)
var segments []string
var totalDuration float64
for _, segment := range mediapl.Segments {
if segment != nil && segment.URI != "" {
segmentURL := segment.URI
if !strings.HasPrefix(segmentURL, "http://") && !strings.HasPrefix(segmentURL, "https://") {
segmentURL = resolveURL(baseURLObj, segmentURL)
}
segments = append(segments, segmentURL)
totalDuration += segment.Duration
}
}
format := &models.MediaFormat{
Type: enums.MediaTypeVideo,
FormatID: "hls",
VideoCodec: enums.MediaCodecAVC,
AudioCodec: enums.MediaCodecAAC,
Duration: int64(totalDuration),
URL: []string{baseURL},
Segments: segments,
}
return []*models.MediaFormat{format}, nil
}
return nil, errors.New("unsupported m3u8 playlist type")
}
func ParseM3U8FromURL(url string) ([]*models.MediaFormat, error) {
body, err := fetchContent(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch m3u8 content: %w", err)
}
return ParseM3U8Content(body, url)
}
func fetchContent(url string) ([]byte, error) {
resp, err := httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch content: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func getCodecFromCodecs(codecs string) enums.MediaCodec {
if strings.Contains(codecs, "avc") || strings.Contains(codecs, "h264") {
return enums.MediaCodecAVC
} else if strings.Contains(codecs, "hvc") || strings.Contains(codecs, "h265") {
return enums.MediaCodecHEVC
} else if strings.Contains(codecs, "av01") {
return enums.MediaCodecAV1
} else if strings.Contains(codecs, "vp9") {
return enums.MediaCodecVP9
} else if strings.Contains(codecs, "vp8") {
return enums.MediaCodecVP8
}
return enums.MediaCodecAVC
}
func getAudioCodecFromCodecs(codecs string) enums.MediaCodec {
if strings.Contains(codecs, "mp4a") {
return enums.MediaCodecAAC
} else if strings.Contains(codecs, "opus") {
return enums.MediaCodecOpus
} else if strings.Contains(codecs, "mp3") {
return enums.MediaCodecMP3
} else if strings.Contains(codecs, "flac") {
return enums.MediaCodecFLAC
} else if strings.Contains(codecs, "vorbis") {
return enums.MediaCodecVorbis
}
return enums.MediaCodecAAC
}
func resolveURL(base *url.URL, uri string) string {
if strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") {
return uri
}
ref, err := url.Parse(uri)
if err != nil {
return uri
}
return base.ResolveReference(ref).String()
}

1
util/parser/mpd.go Normal file
View file

@ -0,0 +1 @@
package parser