You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

726 lines
21 KiB

package main
import (
"context"
"errors"
"fmt"
// "github.com/wailsapp/wails/v2/pkg/options/mac"
"log"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
. "ytd/constants"
. "ytd/db"
. "ytd/models"
. "ytd/plugins"
"github.com/denisbrodbeck/machineid"
"github.com/leonelquinteros/gotext"
"github.com/mitchellh/mapstructure"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options/dialog"
"github.com/xujiajun/nutsdb"
)
var i18nPath string
var wailsRuntime *wails.Runtime
type HostInfo struct {
ID string `json:"id"`
Username string `json:"username"`
}
type AppState struct {
runtime *wails.Runtime
db *nutsdb.DB
plugins []Plugin
Entries []GenericEntry `json:"entries"`
OfflinePlaylists []OfflinePlaylist `json:"offlinePlaylists"`
Config *AppConfig `json:"config"`
Stats *AppStats `json:"stats"`
AppVersion string `json:"appVersion"`
Host HostInfo `json:"host"`
PwaUrl string `json:"pwaUrl"`
context.Context `json:"-"`
isInForeground bool
canStartAtLogin bool
tray TrayMenu
updater *Updater
offlinePlaylistService *OfflinePlaylistService
ngrok *NgrokService
convertQueue chan GenericEntry
}
func (state *AppState) PreWailsInit(ctx context.Context) {
state.db = InitializeDb()
state.Entries = DbGetAllEntries()
state.OfflinePlaylists = DbGetAllOfflinePlaylists()
state.offlinePlaylistService = &OfflinePlaylistService{}
state.ngrok = &NgrokService{}
state.ngrok.Context = ctx
state.Config = state.Config.Init()
state.AppVersion = version
state.convertQueue = make(chan GenericEntry)
// configure i18n paths
gotext.Configure("/Users/oskarmarciniak/projects/golang/ytd/i18n", state.Config.Language, "default")
/*if _, err := mac.StartsAtLogin(); err == nil {
state.canStartAtLogin = true
}*/
if machineid, err := machineid.ProtectedID("ytd"); err == nil {
state.Host.ID = machineid
}
if user, err := user.Current(); err == nil {
state.Host.Username = user.Username
}
}
func (state *AppState) WailsInit(runtime *wails.Runtime) {
// Save runtime
state.runtime = runtime
state.offlinePlaylistService.runtime = runtime
state.ngrok.runtime = runtime
state.Config.SetRuntime(runtime)
// Do some other initialisation
state.Stats = &AppStats{}
appState = state
// this is sync so it blocks until finished and wails:loaded are not dispatched until this finishes
if runtime.System.AppType() == "default" { // wails serve & ng serve
runtime.Events.On("wails:loaded", func(...interface{}) {
time.Sleep(100 * time.Millisecond)
fmt.Println("EMIT YTD:ONLOAD")
runtime.Events.Emit("ytd:onload", state)
})
} else { // dekstop build
go func() {
runtime.Events.Emit("ytd:onload", state)
}()
}
// initialize plugins
for _, plugin := range plugins {
plugin.SetWailsRuntime(runtime)
plugin.SetContext(state.Context)
plugin.SetAppConfig(state.Config)
plugin.SetAppStats(state.Stats)
}
fmt.Println("APP STATE INITIALIZED")
state.InitializeListeners()
// create app sys tray menu
state.tray.runtime = runtime
state.tray.createTray()
state.runtime.Menu.SetTrayMenu(state.tray.defaultTrayMenu)
// event emitted from fe if tray should be updated
runtime.Events.On("ytd:app:tray:update", func(data ...interface{}) {
state.tray.reRenderTray(func() {})
})
state.checkStartsAtLogin()
/* go func() {
for {
time.Sleep(10 * time.Second)
state.checkForTracksToDownload()
}
}() */
go func() {
ticker := time.NewTicker(3 * time.Second)
pending := make(chan int, maxConvertJobs)
for {
select {
case entry := <-state.convertQueue:
go func() {
if len(pending) == cap(pending) {
state.runtime.Events.Emit("ytd:track:convert:queued", entry)
}
pending <- 1
state.convertToMp3(ticker, &entry, true)
<-pending
}()
case <-ticker.C:
// get entries that could be converted
for _, t := range DbGetAllEntries() {
entry := t
plugin := getPluginFor(entry.Source)
if entry.Type == "track" && entry.Track.Status == TrackStatusDownladed && !entry.Track.IsConvertedToMp3 && plugin.IsTrackFileExists(entry.Track, "webm") {
// skip tracks which has failed at least 3 times in a row
if entry.Track.ConvertingStatus.Attempts >= 3 {
fmt.Printf("Skipping audio extraction for %s(%s)...due to too many attempts\n", entry.Track.Name, entry.Track.ID)
continue
}
go func() {
// this will block until pending channel is full
pending <- 1
state.convertToMp3(ticker, &entry, false)
<-pending
}()
}
}
default:
fmt.Printf("Convert to mp3 nothing to do....max parralel %d | converting %d | pending %d entries\n\n", maxConvertJobs, state.Stats.ConvertingCount, len(pending))
time.Sleep(1 * time.Second)
}
}
}()
go func() {
time.Sleep(10 * time.Second)
for {
state.checkForUpdates()
// check again in twelve hours
time.Sleep(12 * time.Hour)
}
}()
if state.Config.PublicServer.Enabled {
ShowLoader(state.runtime, "Starting public server...")
result := state.ngrok.StartProcess(false)
if result.err != nil {
state.runtime.Events.Emit("ytd:ngrok", NgrokStateEventPayload{Status: NgrokStatusError, ErrCode: result.errCode})
HideLoader(state.runtime)
return
}
state.runtime.Events.Emit("ytd:ngrok", NgrokStateEventPayload{Status: result.status, Url: result.publicUrl})
HideLoader(state.runtime)
// monitor ngrok state
go state.ngrok.MonitorNgrokProcess()
}
}
func (state *AppState) WailsShutdown() {
err := state.ngrok.KillProcess()
if err != nil {
fmt.Println("WailsShutdown state.ngrok.KillProcess() failed", err)
}
err = state.db.Merge()
if err != nil {
fmt.Println("WailsShutdown db.Merge() failed", err)
}
CloseDb()
}
func (state *AppState) ReloadNewLanguage() {
gotext.Configure(i18nPath, state.Config.Language, "default")
// re render tray to take effect for new language translations
state.tray.reRenderTray(func() {})
// do other stuff if needed to reload translations from some ui native elements
}
func (state *AppState) InitializeListeners() {
state.runtime.Events.On("ytd:app:foreground", func(optionalData ...interface{}) {
var json map[string]interface{} = optionalData[0].(map[string]interface{})
if isInForeground, ok := json["isInForeground"]; ok {
state.isInForeground = isInForeground.(bool)
}
})
state.runtime.Events.On("ytd:offline:playlists:addedTrack", func(optionalData ...interface{}) {
state.OfflinePlaylists, _ = state.offlinePlaylistService.GetPlaylists(true)
})
state.runtime.Events.On("ytd:offline:playlists:removedTrack", func(optionalData ...interface{}) {
state.OfflinePlaylists, _ = state.offlinePlaylistService.GetPlaylists(true)
})
state.runtime.Events.On("ytd:offline:playlists:created", func(optionalData ...interface{}) {
state.OfflinePlaylists, _ = state.offlinePlaylistService.GetPlaylists(true)
})
state.runtime.Events.On("ytd:offline:playlists:removed", func(optionalData ...interface{}) {
state.OfflinePlaylists, _ = state.offlinePlaylistService.GetPlaylists(true)
})
state.runtime.Events.On("ngrok:configured", func(optionalData ...interface{}) {
if state.Config.PublicServer.Enabled {
ShowLoader(state.runtime, "Configuring public server...")
result := state.ngrok.StartProcess(true)
if result.err != nil {
state.runtime.Events.Emit("ytd:ngrok", NgrokStateEventPayload{Status: NgrokStatusError, ErrCode: result.errCode})
HideLoader(state.runtime)
return
}
state.runtime.Events.Emit("ytd:ngrok", NgrokStateEventPayload{Status: result.status, Url: result.publicUrl})
HideLoader(state.runtime)
// monitor ngrok process
go state.ngrok.MonitorNgrokProcess()
}
if !state.Config.PublicServer.Enabled {
err := state.ngrok.KillProcess()
if err != nil {
SendNotification(state.runtime, NotificationEventPayload{Type: "error", Label: "Cannot shutdown public server"}, state.isInForeground)
}
}
})
}
func (state *AppState) GetAppConfig() *AppConfig {
return state.Config
}
func (state *AppState) SelectDirectory() (string, error) {
selectedDirectory, err := state.runtime.Dialog.OpenDirectory(&dialog.OpenDialog{
AllowFiles: false,
CanCreateDirectories: true,
AllowDirectories: true,
Title: gotext.Get("Choose directory"),
})
return selectedDirectory, err
}
func (state *AppState) GetEntryById(entry GenericEntry) *GenericEntry {
for _, t := range state.Entries {
if t.Type == "track" && t.Track.ID == entry.Track.ID {
return &t
}
}
return nil
}
func (state *AppState) checkForTracksToDownload() error {
fmt.Printf("Check for tracks to start downloads...%d/%d\n", state.Stats.DownloadingCount, state.Config.MaxParrallelDownloads)
if state.Stats.DownloadingCount >= state.Config.MaxParrallelDownloads {
return nil
}
fmt.Printf("Checking...%d entries\n", len(state.Entries))
// range over DbGetAllEntries()
for _, t := range DbGetAllEntries() {
var freeSlots uint = state.Config.MaxParrallelDownloads - state.Stats.DownloadingCount
entry := t
// auto download only tracks with processing status
// if track has pending/failed status it means that something goes wrong so user have to download it manually from UI
if entry.Type == "track" && entry.Track.Status == TrackStatusProcessing {
if freeSlots == 0 {
return nil
}
// start download for track
fmt.Printf("Found %s to download\n", entry.Track.Name)
plugin := getPluginFor(entry.Source)
// make chan GenericEntry
// goriutine scrive li dentro
if plugin != nil {
freeSlots--
go func(entry GenericEntry) {
// storedEntry := state.GetEntryById(entry)
// fmt.Printf("Stored entry %v\n", storedEntry)
// fmt.Println(entry.Track.Url)
// fmt.Println(storedEntry.Track.Url)
plugin.StartDownload(&entry)
// storedEntry.Track = entry.Track
}(entry)
}
}
}
// qui un for che legge dal channel e ogni volta che riceve una entry downloadata
return nil
}
func (state *AppState) convertToMp3(restartTicker *time.Ticker, entry *GenericEntry, force bool) error {
if !state.Config.ConvertToMp3 {
// if option is not enabled restart check after 3s
restartTicker.Stop()
restartTicker.Reset(3 * time.Second)
return nil
}
fmt.Println("Converting....")
ffmpeg, _ := state.IsFFmpegInstalled()
plugin := getPluginFor(entry.Source)
if entry.Type == "track" && entry.Track.Status == TrackStatusDownladed && !entry.Track.IsConvertedToMp3 && plugin.IsTrackFileExists(entry.Track, "webm") {
// skip tracks which has failed at least 3 times in a row
if entry.Track.ConvertingStatus.Attempts >= 3 && !force {
fmt.Printf("Skipping audio extraction for %s - (%s)...due to too many attempts\n", entry.Track.Name, entry.Track.ID)
return nil
}
fmt.Printf("Extracting audio for %s - (%s)...\n", entry.Track.Name, entry.Track.ID)
entry.Track.ConvertingStatus.Status = TrakcConverting
DbWriteEntry(entry.Track.ID, entry)
state.Stats.IncConvertCount()
state.runtime.Events.Emit("ytd:track", entry)
// ffmpeg -i "41qC3w3UUkU.webm" -vn -ab 128k -ar 44100 -y "41qC3w3UUkU.mp3"
outputPath := fmt.Sprintf("%s/%s.mp3", plugin.GetDir(), entry.Track.ID)
cmd := exec.CommandContext(
state.Context,
ffmpeg,
"-loglevel", "quiet",
"-i", fmt.Sprintf("%s/%s.webm", plugin.GetDir(), entry.Track.ID),
"-vn",
"-ab", "128k",
"-ar", "44100",
"-y", outputPath,
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("Failed to extract audio:", err)
entry.Track.ConvertingStatus.Status = TrakcConvertFailed
entry.Track.ConvertingStatus.Err = err.Error()
entry.Track.ConvertingStatus.Attempts += 1
DbWriteEntry(entry.Track.ID, entry)
state.Stats.DecConvertCount()
state.runtime.Events.Emit("ytd:track", entry)
} else {
entry.Track.ConvertingStatus.Status = TrakcConverted
entry.Track.IsConvertedToMp3 = true
// check new filesize and save it
fileInfo, err := os.Stat(outputPath)
if err == nil {
entry.Track.ConvertingStatus.Filesize = int(fileInfo.Size())
}
DbWriteEntry(entry.Track.ID, entry)
state.Stats.DecConvertCount()
state.runtime.Events.Emit("ytd:track", entry)
// remove webm if needed
if state.Config.CleanWebmFiles && plugin.IsTrackFileExists(entry.Track, "webm") {
err = os.Remove(fmt.Sprintf("%s/%s.webm", plugin.GetDir(), entry.Track.ID))
if err != nil && !os.IsNotExist(err) {
fmt.Printf("Cannot remove %s.webm file after successfull converting to mp3\n", entry.Track.ID)
}
}
}
return nil
}
return nil
}
func (state *AppState) checkForUpdates() {
if !state.Config.CheckForUpdates {
return
}
state.updater = &Updater{
CurrentVersion: version,
LatestReleaseGitHubEndpoint: "https://api.github.com/repos/marcio199226/ytd/releases",
Client: &http.Client{Timeout: 10 * time.Minute},
SelectAsset: func(release Release, asset Asset) bool {
// look for the zip file
return strings.Contains(asset.Name, fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)) && filepath.Ext(asset.Name) == ".zip"
},
DownloadBytesLimit: 10_741_824, // 10MB
}
latest, hasUpdate, err := state.updater.HasUpdate()
if err != nil {
_, err := state.runtime.Dialog.Message(&dialog.MessageDialog{
Type: dialog.ErrorDialog,
Title: gotext.Get("Update check failed"),
Message: err.Error(),
Buttons: []string{"OK"},
CancelButton: "OK",
})
if err != nil {
log.Println(err)
return
}
return
}
if !hasUpdate {
_, err := state.runtime.Dialog.Message(&dialog.MessageDialog{
Type: dialog.InfoDialog,
Title: gotext.Get("You're up to date"),
Message: fmt.Sprintf(gotext.Get("%s is the latest version.", latest.TagName)),
Buttons: []string{"OK"},
CancelButton: "OK",
})
if err != nil {
log.Println(err)
return
}
return
}
clickedAction, _ := state.runtime.Dialog.Message(&dialog.MessageDialog{
Type: dialog.InfoDialog,
Title: fmt.Sprintf(gotext.Get("New version available: %s", latest.TagName)),
Message: gotext.Get("Would you like to update?"),
Buttons: []string{"OK", "Changelog", gotext.Get("Update")},
CancelButton: "OK",
})
state.tray.reRenderTray(func() {
state.tray.versionMenuItem.Label = fmt.Sprintf(gotext.Get("⚠️ ytd (%s) (new version %s)", version, latest.TagName))
})
switch clickedAction {
case "Update":
// continue
case "Changelog":
state.runtime.Window.Show()
state.runtime.Events.Emit("ytd:app:update:changelog", latest)
return
case "OK":
state.runtime.Events.Emit("ytd:app:update:available", latest)
return
}
state.Update(false)
}
func (state *AppState) Update(restart bool) {
_, err := state.updater.Update()
if err != nil {
_, err := state.runtime.Dialog.Message(&dialog.MessageDialog{
Type: dialog.WarningDialog,
Title: gotext.Get("Update not successful"),
Message: err.Error(),
Buttons: []string{"OK"},
CancelButton: "OK",
})
if err != nil {
log.Println(err)
return
}
}
// restart for now does not work properly, it closes current ytd instance but not launch the new one
// so notify user that update has been done and could restart app
// err = u.Restart()
state.runtime.Dialog.Message(&dialog.MessageDialog{
Type: dialog.InfoDialog,
Title: gotext.Get("Update successful"),
Message: gotext.Get("Please restart ytd for the changes to take effect."),
Buttons: []string{"OK"},
CancelButton: "OK",
})
}
func (state *AppState) checkStartsAtLogin() {
/*startsAtLogin, err := mac.StartsAtLogin()
if err != nil {
state.tray.reRenderTray(func() {
state.tray.startAtLoginMenuItem.Label = gotext.Get("⚠ Start at Login unavailable")
state.tray.startAtLoginMenuItem.Disabled = true
})
} else if startsAtLogin {
mac.ShowNotification("Ytd", gotext.Get("App has been started in background"), "", "")
state.tray.reRenderTray(func() {
state.tray.startAtLoginMenuItem.Checked = true
state.tray.startAtLoginMenuItem.Disabled = false
})
}*/
}
func (state *AppState) SaveSettingBoolValue(name string, val bool) (err error) {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovering from panic saveSettingValue:", r)
switch x := r.(type) {
case string:
err = errors.New(x)
case error:
err = x
default:
// Fallback err (per specs, error strings should be lowercase w/o punctuation
err = errors.New("unknown panic")
}
}
}()
error := DbSaveSettingBoolValue(name, val)
if err != nil {
return error
}
appState.Config.Set(name, val)
return nil
}
func (state *AppState) SaveSettingValue(name string, val string) (err error) {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovering from panic saveSettingValue:", r)
switch x := r.(type) {
case string:
err = errors.New(x)
case error:
err = x
default:
// Fallback err (per specs, error strings should be lowercase w/o punctuation
err = errors.New("unknown panic")
}
}
}()
error := DbWriteSetting(name, val)
if err != nil {
return error
}
appState.Config.Set(name, val)
return nil
}
func (state *AppState) ReadSettingBoolValue(name string) (bool, error) {
return DbReadSettingBoolValue(name)
}
func (state *AppState) ReadSettingValue(name string) (string, error) {
return DbReadSetting(name)
}
func (state *AppState) RemoveEntry(record map[string]interface{}) error {
var err error
var entry GenericEntry
err = mapstructure.Decode(record, &entry)
if err != nil {
return err
}
if entry.Type == "track" {
if err = DbDeleteEntry(entry.Track.ID); err == nil {
plugin := getPluginFor(entry.Source)
if plugin.IsTrackFileExists(entry.Track, "webm") {
err = os.Remove(fmt.Sprintf("%s/%s.webm", plugin.GetDir(), entry.Track.ID))
if err != nil && !os.IsNotExist(err) {
return err
}
}
// remove mp3 if file has been already converted
if plugin.IsTrackFileExists(entry.Track, "mp3") {
err = os.Remove(fmt.Sprintf("%s/%s.mp3", plugin.GetDir(), entry.Track.ID))
if err != nil && !os.IsNotExist(err) {
return err
}
}
// remove track from playlists
for _, p := range state.OfflinePlaylists {
for _, tid := range p.TracksIds {
if tid == entry.Track.ID {
state.offlinePlaylistService.RemoveTrackFromPlaylist(tid, p)
}
}
}
state.OfflinePlaylists, _ = state.offlinePlaylistService.GetPlaylists(true)
return nil
}
}
return err
}
func (state *AppState) AddToDownload(url string, isFromClipboard bool) error {
for _, plugin := range plugins {
if support := plugin.Supports(url); support {
if appState.GetAppConfig().ConcurrentDownloads {
go func() {
newEntries <- plugin.Fetch(url, isFromClipboard)
}()
} else {
newEntries <- plugin.Fetch(url, isFromClipboard)
}
continue
}
}
return nil
}
func (state *AppState) StartDownload(record map[string]interface{}) error {
var err error
var entry GenericEntry
err = mapstructure.Decode(record, &entry)
if err != nil {
return err
}
if appState.Stats.DownloadingCount >= appState.Config.MaxParrallelDownloads {
return errors.New("Max simultaneous downloads are reached please retry after some track finished downloading")
}
for _, plugin := range plugins {
if plugin.GetName() == entry.Source && entry.Type == "track" {
if appState.GetAppConfig().ConcurrentDownloads {
go func() {
plugin.StartDownload(&entry)
}()
} else {
plugin.StartDownload(&entry)
}
continue
}
}
return nil
}
func (state *AppState) AddToConvertQueue(entry GenericEntry) error {
state.convertQueue <- entry
return nil
}
func (state *AppState) IsSupportedUrl(url string) bool {
for _, plugin := range plugins {
if support := plugin.Supports(url); support {
return true
}
}
return false
}
func (state *AppState) IsFFmpegInstalled() (string, error) {
ffmpeg, err := exec.LookPath("ffmpeg")
if state.runtime.System.Platform() == "darwin" && err != nil {
// on darwin check if ffmpeg is maybe installed by homebrew
// (searching for ffmpeg only give wrong results if installed with homebrew)
ffmpeg, err := exec.LookPath("/opt/homebrew/bin/ffmpeg")
return ffmpeg, err
}
return ffmpeg, err
}
func (state *AppState) OpenUrl(url string) error {
return state.runtime.Browser.Open(url)
}
func (state *AppState) ForceQuit() {
state.runtime.Quit()
}
func (state *AppState) ShowWindow() {
state.runtime.Window.Show()
}
func getPluginFor(name string) Plugin {
for _, plugin := range plugins {
if plugin.GetName() == name {
return plugin
}
}
return nil
}
//WailsRuntime .
type WailsRuntime struct {
runtime *wails.Runtime
}