Init
This commit is contained in:
parent
264c97183e
commit
3faede7b1c
74 changed files with 6228 additions and 1 deletions
10
.env.example
Normal file
10
.env.example
Normal 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
16
.gitignore
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
*.exe
|
||||
*.json
|
||||
*.txt
|
||||
*.py
|
||||
*.html
|
||||
|
||||
old/
|
||||
|
||||
.env
|
||||
.idea/
|
||||
|
||||
downloads
|
||||
|
||||
govd
|
||||
|
||||
.DS_Store
|
2
LICENSE
2
LICENSE
|
@ -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
53
README.md
Normal 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
134
bot/core/default.go
Normal 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
187
bot/core/download.go
Normal 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
251
bot/core/inline.go
Normal 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
147
bot/core/main.go
Normal 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
280
bot/core/util.go
Normal 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
44
bot/handlers/ext.go
Normal 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
45
bot/handlers/help.go
Normal 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
91
bot/handlers/inline.go
Normal 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
72
bot/handlers/instances.go
Normal 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
213
bot/handlers/settings.go
Normal 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
88
bot/handlers/start.go
Normal 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
89
bot/handlers/stats.go
Normal 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
65
bot/handlers/url.go
Normal 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
118
bot/main.go
Normal 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
56
bot/middleware.go
Normal 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
17
build.sh
Normal 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
1
cookies/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
*.txt
|
65
database/main.go
Normal file
65
database/main.go
Normal 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
60
database/media.go
Normal 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
37
database/settings.go
Normal 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
52
database/stats.go
Normal 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
38
database/user.go
Normal 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
8
enums/chat_type.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package enums
|
||||
|
||||
type ChatType string
|
||||
|
||||
const (
|
||||
ChatTypePrivate ChatType = "private"
|
||||
ChatTypeGroup ChatType = "group"
|
||||
)
|
9
enums/extractor_category.go
Normal file
9
enums/extractor_category.go
Normal 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
7
enums/extractor_type.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package enums
|
||||
|
||||
type ExtractorType string
|
||||
|
||||
const (
|
||||
ExtractorTypeSingle ExtractorType = "single"
|
||||
)
|
17
enums/media_codec.go
Normal file
17
enums/media_codec.go
Normal 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
9
enums/media_type.go
Normal 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
169
ext/instagram/main.go
Normal 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
19
ext/instagram/models.go
Normal 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
139
ext/instagram/util.go
Normal 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=".*?: "(.*?)""`,
|
||||
)
|
||||
|
||||
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
24
ext/main.go
Normal 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
172
ext/pinterest/main.go
Normal 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
62
ext/pinterest/models.go
Normal 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
55
ext/pinterest/util.go
Normal 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
267
ext/reddit/main.go
Normal 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
74
ext/reddit/models.go
Normal 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
39
ext/reddit/util.go
Normal 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
184
ext/tiktok/main.go
Normal 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
65
ext/tiktok/models.go
Normal 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
177
ext/tiktok/util.go
Normal 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
181
ext/twitter/main.go
Normal 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
72
ext/twitter/models.go
Normal 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
162
ext/twitter/util.go
Normal 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
74
ext/util.go
Normal 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
52
go.mod
Normal 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
162
go.sum
Normal 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
25
main.go
Normal 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
9
models/ctx.go
Normal 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
14
models/download.go
Normal 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
34
models/ext.go
Normal 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
506
models/media.go
Normal 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
11
models/misc.go
Normal 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
3
models/plugin.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package models
|
||||
|
||||
type Plugin = func(*DownloadedMedia) error
|
12
models/settings.go
Normal file
12
models/settings.go
Normal 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
14
models/user.go
Normal 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
7
plugins/main.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package plugins
|
||||
|
||||
import "govd/models"
|
||||
|
||||
var List = []models.Plugin{
|
||||
MergeAudio,
|
||||
}
|
40
plugins/merge_audio.go
Normal file
40
plugins/merge_audio.go
Normal 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
23
util/av/audio.go
Normal 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
46
util/av/merge_audio.go
Normal 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
35
util/av/remux.go
Normal 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
27
util/av/thumbnail.go
Normal 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
18
util/av/videoinfo.go
Normal 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
5
util/consts.go
Normal 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
512
util/download.go
Normal 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
23
util/errors.go
Normal 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
14
util/http.go
Normal 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
126
util/img.go
Normal 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
112
util/misc.go
Normal 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{
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
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, "&", "&")
|
||||
}
|
||||
|
||||
func CheckFFmpeg() bool {
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
return err == nil
|
||||
}
|
182
util/parser/m3u8.go
Normal file
182
util/parser/m3u8.go
Normal 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
1
util/parser/mpd.go
Normal file
|
@ -0,0 +1 @@
|
|||
package parser
|
Loading…
Add table
Add a link
Reference in a new issue