From 6dc53c53c8e427fa256c13dfd1e14f434ec13cb7 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:16:33 +0100 Subject: [PATCH 01/77] Update 0 initial.sql --- .../service/migration/scripts/0 initial.sql | 112 ++++++------------ 1 file changed, 33 insertions(+), 79 deletions(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 73daa89..a273a20 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -1,90 +1,44 @@ -- 1. Schema Setup -CREATE SCHEMA IF NOT EXISTS pgmq_public; CREATE SCHEMA IF NOT EXISTS librebeats; - --- -- Source https://github.com/orgs/supabase/discussions/41729#discussion-9312472 --- 2. WRAPPER FUNCTIONS --- Note the addition of SECURITY DEFINER and explicit search_path -CREATE OR REPLACE FUNCTION pgmq_public.pop(queue_name text) -RETURNS SETOF pgmq.message_record LANGUAGE plpgsql -SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$ -BEGIN - RETURN QUERY SELECT * FROM pgmq.pop(queue_name := queue_name); -END; $$; -COMMENT ON FUNCTION pgmq_public.pop(queue_name text) IS -'Retrieves and locks the next message from the specified queue.'; - - -CREATE OR REPLACE FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer DEFAULT 0) -RETURNS SETOF bigint LANGUAGE plpgsql -SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$ -BEGIN - RETURN QUERY SELECT * FROM pgmq.send(queue_name := queue_name, msg := message, delay := sleep_seconds); -END; $$; -COMMENT ON FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer) IS -'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.'; - -CREATE OR REPLACE FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer) -RETURNS SETOF pgmq.message_record LANGUAGE plpgsql -SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$ -BEGIN - RETURN QUERY SELECT * FROM pgmq.read(queue_name := queue_name, vt := sleep_seconds, qty := n); -END; $$; -COMMENT ON FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer) IS -'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; - -CREATE OR REPLACE FUNCTION pgmq_public.archive(queue_name text, message_id bigint) -RETURNS boolean LANGUAGE plpgsql -SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$ -BEGIN - RETURN pgmq.archive(queue_name := queue_name, msg_id := message_id); -END; $$; -COMMENT ON FUNCTION pgmq_public.archive(queue_name text, message_id bigint) IS -'Archives a message by moving it from the queue to a permanent archive.'; - -CREATE OR REPLACE FUNCTION pgmq_public.delete(queue_name text, message_id bigint) -RETURNS boolean LANGUAGE plpgsql -SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$ -BEGIN - RETURN pgmq.delete(queue_name := queue_name, msg_id := message_id); -END; $$; -COMMENT ON FUNCTION pgmq_public.delete(queue_name text, message_id bigint) IS -'Deletes a message from the specified queue.'; - -CREATE OR REPLACE FUNCTION pgmq_public.create_queue(queue_name text) -RETURNS void -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pgmq, pgmq_public -AS $$ -BEGIN - -- This calls the extension's internal create function - PERFORM pgmq.create(queue_name := queue_name); -END; -$$; -COMMENT ON FUNCTION pgmq_public.create_queue(queue_name text) IS -'Creates a new message queue with the specified name.'; - --- Grant access to the service role -GRANT EXECUTE ON FUNCTION pgmq_public.create_queue(text) TO service_role; - --- 3. Permission Cleanup --- Grant EXECUTE to service_role only -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq_public TO service_role; - -- 4. Internal Table -CREATE TABLE IF NOT EXISTS librebeats.migrations ( - id serial PRIMARY KEY, - file_name text NOT NULL, - content text NOT NULL, - run_on timestamptz NOT NULL DEFAULT now() +CREATE TABLE IF NOT EXISTS Librebeats.Migrations( + Id serial PRIMARY KEY, + FileName text NOT NULL, + Content text NOT NULL, + RunOn timestamptz NOT NULL DEFAULT now() ); -- Enable RLS: With no policies, it is accessible ONLY by service_role -ALTER TABLE librebeats.migrations ENABLE ROW LEVEL SECURITY; +ALTER TABLE Librebeats.Migrations ENABLE ROW LEVEL SECURITY; -- 5. Final Grants -GRANT USAGE ON SCHEMA librebeats, pgmq_public, pgmq TO service_role; +-- GRANT USAGE ON SCHEMA librebeats, pgmq_public, pgmq TO service_role; GRANT ALL ON ALL TABLES IN SCHEMA librebeats TO service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA librebeats TO service_role; + +-- Create the audio processing queue +SELECT * FROM pgmq.create('audiopipe-input'); + +-- Example insert into the queue +-- SELECT * FROM pgmq.send('audiopipe-input', '{"key": "path/to/audio/file.mp3", "metadata": {"artist": "Artist Name", "album": "Album Name"}}', 0); + +CREATE TABLE IF NOT EXISTS Librebeats.Audio ( + Id serial PRIMARY KEY, + SourceId TEXT NOT NULL, + SourceName TEXT NOT NULL, + StorageLocation TEXT, + ThumbnailLocation TEXT, + ProgressState INT NOT NULL, + DownloadCount INT NOT NULL DEFAULT 0, + CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( + Id INT PRIMARY KEY, + AudioId serial NOT NULL, + Title TEXT NOT NULL, + OutputLogBase64 TEXT NOT NULL, + CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW(), + FinishedAtUtc TIMESTAMP +); From a9d54b8e55475eac610c2e0ec0ac47b4af7a63a6 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:16:46 +0100 Subject: [PATCH 02/77] Update migration.go --- .../supabase/service/migration/migration.go | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/backend/supabase/service/migration/migration.go b/src/backend/supabase/service/migration/migration.go index 6c73a5e..f44dc82 100644 --- a/src/backend/supabase/service/migration/migration.go +++ b/src/backend/supabase/service/migration/migration.go @@ -36,7 +36,13 @@ func NewMigrationInstance() IMaigration { func (m *Migration) Run() error { - lastAppliedMigrationId := m._LastAppliedMigrationId() + lastAppliedMigrationId, err := m._LastAppliedMigrationId() + + if err != nil { + return err + } + + fmt.Println("Last applied migration id:", lastAppliedMigrationId) tx, err := m._connection.Begin(context.Background()) @@ -59,8 +65,10 @@ func (m *Migration) Run() error { for _, dirEntry := range dirs { + fmt.Println("Processing migration file:", dirEntry.Name()) + if dirEntry.IsDir() { - fmt.Println("Already applied skipping folder:", dirEntry.Name()) + fmt.Println("Skipping directory:", dirEntry.Name()) continue } @@ -74,6 +82,7 @@ func (m *Migration) Run() error { } if migrationFileId <= lastAppliedMigrationId { + fmt.Println("Skipping already applied migration:", dirEntry.Name()) continue } @@ -90,7 +99,7 @@ func (m *Migration) Run() error { return err } - _, err = tx.Exec(context.Background(), "INSERT INTO librebeats.migrations (id, file_name, content, run_on) VALUES ($1, $2, $3, NOW())", migrationFileId, dirEntry.Name(), string(sqlScript)) + _, err = tx.Exec(context.Background(), "INSERT INTO Librebeats.Migrations (id, fileName, content, runOn) VALUES ($1, $2, $3, NOW())", migrationFileId, dirEntry.Name(), string(sqlScript)) if err != nil { defer tx.Rollback(context.Background()) @@ -109,15 +118,16 @@ func (m *Migration) Run() error { return nil } -func (m *Migration) _LastAppliedMigrationId() int { +func (m *Migration) _LastAppliedMigrationId() (int, error) { var lastAppliedMigrationId int - err := m._connection.QueryRow(context.Background(), "SELECT id FROM librebeats.migrations ORDER BY run_on DESC LIMIT 1").Scan(&lastAppliedMigrationId) + err := m._connection.QueryRow(context.Background(), "SELECT id FROM librebeats.migrations ORDER BY runon DESC LIMIT 1").Scan(&lastAppliedMigrationId) if err != nil { - return -1 + fmt.Println("Error fetching last applied migration id:", err.Error()) + return -1, err } - return lastAppliedMigrationId + return lastAppliedMigrationId, nil } From 76bbfb5f132d4be70e8d5ea6ff162e62edcec42d Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:16:53 +0100 Subject: [PATCH 03/77] Update main.go --- src/backend/supabase/service/migration/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/service/migration/main.go b/src/backend/supabase/service/migration/main.go index 662b3eb..38a11ce 100644 --- a/src/backend/supabase/service/migration/main.go +++ b/src/backend/supabase/service/migration/main.go @@ -14,7 +14,8 @@ func main() { err := migration.Run() if err != nil { - panic(err) + fmt.Println("Error applying migrations: " + err.Error()) + return } fmt.Println("Migrations applied successfully") From 6ed4e56169fde7b4757df60b46592d2e603396c6 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:16:59 +0100 Subject: [PATCH 04/77] Create sourceHelper.go --- .../supabase/service/audio/sourceHelper.go | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/backend/supabase/service/audio/sourceHelper.go diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go new file mode 100644 index 0000000..4e1fe7a --- /dev/null +++ b/src/backend/supabase/service/audio/sourceHelper.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "log" + "os" +) + +var cookiesPath = "/app/cookies.txt" + +func FlatPlaylistDownload( + archiveFileName string, + idsFileName string, + namesFileName string, + durationFileName string, + playlistTitleFileName string, + playlistIdFileName string, + url string, + logOutput string, + logOutputError string, +) bool { + Stdout, err := os.Create(logOutput) + + if err != nil { + fmt.Println(err) + panic(-65465465) + } + + Stderr, err := os.Create(logOutputError) + + if err != nil { + panic(-65324465) + } + + proc, _err := os.StartProcess( + "/usr/local/bin/yt-dlp", + []string{ + "yt-dlp", + "--force-ipv4", + "--no-keep-video", + "--skip-download", + "--flat-playlist", + "--write-thumbnail", + "--print-to-file", "%(id)s", idsFileName, + "--print-to-file", "%(title)s", namesFileName, + "--print-to-file", "%(duration)s", durationFileName, + "--print-to-file", "%(playlist_id)s", playlistIdFileName, + "--print-to-file", "%(playlist_title)s", playlistTitleFileName, + "--ignore-errors", + "--extractor-args=youtube:player_js_variant=tv", + fmt.Sprintf("--cookies=%s", cookiesPath), + "--js-runtimes=deno:/home/admin/.deno/bin", + "--remote-components=ejs:npm", + url, + }, + &os.ProcAttr{ + Files: []*os.File{ + os.Stdin, /// :)))))))))))))))))))))))))))))))) + Stdout, + Stderr, + }, + }, + ) + if _err != nil { + log.Fatal(_err) + } + + state, err := proc.Wait() + + if err != nil { + return false + } + + return state.Success() +} + +func FlatSingleDownload( + archiveFileName string, + idsFileName string, + namesFileName string, + durationFileName string, + playlistTitleFileName string, + playlistIdFileName string, + url string, + logOutput string, + logOutputError string, + storageFolderName string, + fileExtension string, +) bool { + + Stdout, err := os.OpenFile(logOutput, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + if err != nil { + panic(-65465465) + } + + Stderr, err := os.OpenFile(logOutputError, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + if err != nil { + panic(-65324465) + } + + proc, _err := os.StartProcess( + "/usr/local/bin/yt-dlp", + []string{ + "yt-dlp", + "--force-ipv4", + "--write-thumbnail", + "--extract-audio", + "--audio-quality=0", + fmt.Sprintf("--audio-format=%s", fileExtension), + "--convert-thumbnails=jpg", + "--force-ipv4", + "--downloader=aria2c", + "--no-keep-video", + "--downloader-args=aria2c:-x 16 -s 16 -j 16", + "--print-to-file", "%(id)s", idsFileName, + "--print-to-file", "%(title)s", namesFileName, + "--print-to-file", "%(duration)s", durationFileName, + "--output", "/%(id)s.%(ext)s", + "--concurrent-fragments=20", + "--ignore-errors", + fmt.Sprintf("--download-archive=%s", archiveFileName), + "--extractor-args=youtube:player_js_variant=tv", + fmt.Sprintf("--cookies=%s", cookiesPath), + "--js-runtimes=deno:/home/admin/.deno/bin", + "--remote-components=ejs:npm", + url, + }, + &os.ProcAttr{ + Files: []*os.File{ + os.Stdin, /// :)))))))))))))))))))))))))))))))) + Stdout, + Stderr, + }, + }, + ) + if _err != nil { + log.Fatal(_err) + } + + state, err := proc.Wait() + + if err != nil { + return false + } + + return state.Success() +} From 1bef58d85293ea39c5239c29f0bd4efcf991dc7f Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:06 +0100 Subject: [PATCH 05/77] Create queue.go --- src/backend/supabase/service/audio/queue.go | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/backend/supabase/service/audio/queue.go diff --git a/src/backend/supabase/service/audio/queue.go b/src/backend/supabase/service/audio/queue.go new file mode 100644 index 0000000..7ad730d --- /dev/null +++ b/src/backend/supabase/service/audio/queue.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type IQueueListener interface { + Pop() (map[string]interface{}, error) +} + +type QueueListener struct { + IQueueListener + ConnectionString string + QueueName string +} + +func (ql *QueueListener) Pop() (*AudioPipeQueueMessage, error) { + + conn, err := pgx.Connect(context.Background(), ql.ConnectionString) + + if err != nil { + return nil, err + } + + defer conn.Close(context.Background()) + + var audioQueueMessage AudioPipeQueueMessage + + transaction, err := conn.Begin(context.Background()) + + if err != nil { + return nil, err + } + + err = transaction.QueryRow(context.Background(), fmt.Sprintf("SELECT msg_id, message FROM pgmq.pop('%s')", ql.QueueName)).Scan(&audioQueueMessage.Id, &audioQueueMessage.Message) + + if err != nil { + return nil, err + } + + if err := transaction.Commit(context.Background()); err != nil { + return nil, err + } + + // var keyValues map[string]interface{} + + // if err := json.Unmarshal([]byte(result.Message), &keyValues); err != nil { + // return AudioPipeQueueMessage{}, err + // } + + return &audioQueueMessage, nil +} From 813582122c5f6facf6e21ed931a78da64095145a Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:12 +0100 Subject: [PATCH 06/77] Create models.go --- src/backend/supabase/service/audio/models.go | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/backend/supabase/service/audio/models.go diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go new file mode 100644 index 0000000..67dbabc --- /dev/null +++ b/src/backend/supabase/service/audio/models.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type AudioPipeQueueMessage struct { + Id int64 `json:"msg_id"` + Message json.RawMessage `json:"message"` +} + +type AudioProcessingMessage struct { + Url string `json:"url"` +} + +type Audio struct { + Id uuid.UUID `db:"id"` + SourceId string `db:"source_id"` + SourceName *string `db:"source_name"` // nullable + StorageLocation string `db:"storage_location"` + ThumbnailLocation *string `db:"thumbnail_location"` // nullable + ProgressState int `db:"progress_state"` + DownloadCount int `db:"download_count"` + CreatedAtUtc time.Time `db:"created_at_utc"` +} + +type YtdlpOutputLog struct { + Id uuid.UUID `db:"id"` + AudioId uuid.UUID `db:"audio_id"` + Title *string `db:"title"` // nullable + OutputLogBase64 *string `db:"output_log"` // nullable + CreatedAtUtc time.Time `db:"created_at_utc"` +} From 7729452a70bec3b54fcd09dbac8b28b98e1dfe2c Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:33 +0100 Subject: [PATCH 07/77] Create main.go --- src/backend/supabase/service/audio/main.go | 80 ++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/backend/supabase/service/audio/main.go diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go new file mode 100644 index 0000000..dbf636c --- /dev/null +++ b/src/backend/supabase/service/audio/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "os" + "time" +) + +const ( + SleepTimeInSeconds = 5 +) + +func main() { + // Setup database connection pool + CreateConnectionPool() + + // Create queue listener + var queueListener = *createQueueListener() + + for true { + audioQueueMessage, _ := listenForMessage(&queueListener) + + if audioQueueMessage == nil { + continue + } + + fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message) + + // Works ish + download := FlatSingleDownload("archive.txt", "ids.txt", "names.txt", "duration.txt", "playlist_title.txt", "playlist_id.txt", "https://www.youtube.com/watch?v=s-uEFHxZ_nE", "output.log", "error.log", "", "opus") + + // Handle download result + if !download { + fmt.Println("Failed to download playlist") + } else { + fmt.Println("Playlist downloaded successfully") + } + } +} + +func listenForMessage(queue *QueueListener) (*AudioPipeQueueMessage, error) { + audioQueueMessage, err := queue.Pop() + + if err != nil || audioQueueMessage == nil { + HandleError(err) + sleep() + return nil, err + } + + return audioQueueMessage, nil +} + +func createQueueListener() *QueueListener { + connectionString := os.Getenv("POSTGRES_BACKEND_URL") + queueName := os.Getenv("QUEUE_NAME") + + if connectionString == "" { + panic("POSTGRES_BACKEND_URL environment variable is not set") + } + + if queueName == "" { + panic("QUEUE_NAME environment variable is not set") + } + + return &QueueListener{ + ConnectionString: connectionString, + QueueName: queueName, + } +} + +func sleep() { + fmt.Printf("Sleeping for %d seconds...\n", SleepTimeInSeconds) + time.Sleep(SleepTimeInSeconds * time.Second) +} + +func HandleError(err error) { + if err != nil { + fmt.Println(err.Error()) + } +} From 4344fdbe47e17f8d8a557b082025c2b63d598927 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:39 +0100 Subject: [PATCH 08/77] Create go.sum --- src/backend/supabase/service/audio/go.sum | 192 ++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/backend/supabase/service/audio/go.sum diff --git a/src/backend/supabase/service/audio/go.sum b/src/backend/supabase/service/audio/go.sum new file mode 100644 index 0000000..cc64971 --- /dev/null +++ b/src/backend/supabase/service/audio/go.sum @@ -0,0 +1,192 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/georgysavva/scany v1.2.3 h1:yaEtl1B2i3qjCIsmLchSrcw2MxktvK+N0oi7uzYyqWk= +github.com/georgysavva/scany v1.2.3/go.mod h1:vGBpL5XRLOocMFFa55pj0P04DrL3I7qKVRL49K6Eu5o= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +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/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk= +github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= +github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= +github.com/jackc/pgconn v1.8.0 h1:FmjZ0rOyXTr1wfWs45i4a9vjnjWUAGpMuQLD9OSs+lw= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.0.6 h1:b1105ZGEMFe7aCvrT1Cca3VoVb4ZFMaFJLJcg/3zD+8= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= +github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po= +github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ= +github.com/jackc/pgtype v1.6.2 h1:b3pDeuhbbzBYcg5kwNmNDun4pFUD/0AAr1kLXZLeNt8= +github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA= +github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o= +github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg= +github.com/jackc/pgx/v4 v4.10.1 h1:/6Q3ye4myIj6AaplUm+eRcz4OhK9HAvFf4ePsG40LJY= +github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/supabase-community/postgrest-go v0.0.12 h1:4xJmimJra904t6Rj+umPyu1qm6ih7rhd7fvgqAblajc= +github.com/supabase-community/postgrest-go v0.0.12/go.mod h1:cw6LfzMyK42AOSBA1bQ/HZ381trIJyuui2GWhraW7Cc= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +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.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg= +gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= From f7f717dc7051c8f5fa05aafae2cfa5c00ef8c833 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:45 +0100 Subject: [PATCH 09/77] Create go.mod --- src/backend/supabase/service/audio/go.mod | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/backend/supabase/service/audio/go.mod diff --git a/src/backend/supabase/service/audio/go.mod b/src/backend/supabase/service/audio/go.mod new file mode 100644 index 0000000..1d112e5 --- /dev/null +++ b/src/backend/supabase/service/audio/go.mod @@ -0,0 +1,24 @@ +module audio + +go 1.25.5 + +require github.com/google/uuid v1.6.0 + +require ( + github.com/georgysavva/scany v1.2.3 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.8.0 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.0.6 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgtype v1.6.2 // indirect + github.com/jackc/pgx/v4 v4.10.1 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle v1.1.3 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect +) From a92bff234d7d8d4e696f777a7a8039575ed7888c Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:17:55 +0100 Subject: [PATCH 10/77] Create Dockerfile --- src/backend/supabase/service/audio/Dockerfile | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/backend/supabase/service/audio/Dockerfile diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile new file mode 100644 index 0000000..6372fd1 --- /dev/null +++ b/src/backend/supabase/service/audio/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.26 AS builder +WORKDIR /app +COPY . . +RUN go build -o main + +FROM ubuntu:24.04 +WORKDIR /app + +COPY --from=builder /app/main . + +COPY cookies.txt /app/cookies.txt + +# install ffmpeg +RUN apt-get update && apt-get install -y ffmpeg + +# install wget +RUN apt-get install -y wget + +# install curl +RUN apt-get install -y curl + +# install unzip +RUN apt-get install -y unzip + +# install deno +RUN wget -qO- https://deno.land/install.sh | sh + +# install yt-dlp +RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp && chmod +x /usr/local/bin/yt-dlp + +CMD ["./main"] \ No newline at end of file From f4b294b25c1d3e9c98e51adab95ceb4d9aa257a9 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:18:02 +0100 Subject: [PATCH 11/77] Create database.go --- .../supabase/service/audio/database.go | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/backend/supabase/service/audio/database.go diff --git a/src/backend/supabase/service/audio/database.go b/src/backend/supabase/service/audio/database.go new file mode 100644 index 0000000..12eebd0 --- /dev/null +++ b/src/backend/supabase/service/audio/database.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ReturningIdParameter = "RETURNING" + +var DbInstancePool *pgxpool.Pool + +type BaseTable struct { + Pool *pgxpool.Pool +} + +func NewBaseTableInstance() BaseTable { + return BaseTable{ + Pool: DbInstancePool, + } +} + +func CreateConnectionPool() { + databaseUrl := os.Getenv("POSTGRES_BACKEND_URL") + + pool, err := pgxpool.New(context.Background(), databaseUrl) + if err != nil { + panic(fmt.Sprintf("unable to create connection pool: %v", err)) + } + + // Test connection + err = pool.Ping(context.Background()) + if err != nil { + pool.Close() + panic(fmt.Sprintf("unable to ping database: %v", err)) + } + + DbInstancePool = pool +} + +func (base *BaseTable) InsertWithReturningId(query string, params ...any) (lastInsertedId int, err error) { + + if !strings.Contains(query, ReturningIdParameter) { + return -1, errors.New("Query does not contain RETURNING keyword") + } + + transaction, err := base.Pool.Begin(context.Background()) + if err != nil { + return -1, err + } + + statement, err := transaction.Prepare(context.Background(), "", query) + if err != nil { + transaction.Rollback(context.Background()) + return -1, err + } + defer transaction.Conn().Close(context.Background()) + + err = transaction.QueryRow(context.Background(), statement.SQL, params...).Scan(&lastInsertedId) + + if err != nil { + transaction.Rollback(context.Background()) + return -1, err + } + + err = transaction.Commit(context.Background()) + + if err != nil { + transaction.Rollback(context.Background()) + return -1, err + } + + return lastInsertedId, nil +} + +func (base *BaseTable) NonScalarQuery(query string, params ...any) (error error) { + + transaction, err := base.Pool.Begin(context.Background()) + + if err != nil { + return err + } + + defer transaction.Conn().Close(context.Background()) + + statement, err := transaction.Prepare(context.Background(), "", query) + + if err != nil { + transaction.Rollback(context.Background()) + return err + } + + _, err = transaction.Exec(context.Background(), statement.SQL, params...) + + if err != nil { + transaction.Rollback(context.Background()) + return err + } + + err = transaction.Commit(context.Background()) + + if err != nil { + transaction.Rollback(context.Background()) + return err + } + + return nil +} + +func (base *BaseTable) QueryRow(query string, params ...any) (pgx.Row, error) { + pool, err := base.Pool.Acquire(context.Background()) + + if err != nil { + return nil, err + } + + return pool.QueryRow(context.Background(), query, params...), nil +} + +func (base *BaseTable) QueryRows(query string) (pgx.Rows, error) { + pool, err := base.Pool.Acquire(context.Background()) + + if err != nil { + return nil, err + } + + return pool.Query(context.Background(), query, nil) +} From 6c54294ceeed37fe47be73fdf1eeb22852dec3fe Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:18:25 +0100 Subject: [PATCH 12/77] Update docker-compose.yml --- src/backend/supabase/docker-compose.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/backend/supabase/docker-compose.yml b/src/backend/supabase/docker-compose.yml index 2665d41..d46ffcd 100644 --- a/src/backend/supabase/docker-compose.yml +++ b/src/backend/supabase/docker-compose.yml @@ -400,10 +400,22 @@ services: db: condition: service_healthy environment: - SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} - SUPABASE_URL: http://kong:8000 POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres - JWT_SECRET: ${JWT_SECRET} + + audio: + container_name: supabase-audio + build: + context: ./service/audio + dockerfile: Dockerfile + restart: "no" + depends_on: + migrations: + condition: service_completed_successfully + db: + condition: service_healthy + environment: + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres + QUEUE_NAME: audiopipe-input # Comment out everything below this point if you are using an external Postgres database db: From 6e7b44cc1b241271f0ca05cf920be3f4bb4d9bf1 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 22 Feb 2026 21:52:58 +0100 Subject: [PATCH 13/77] reminder --- src/backend/supabase/service/audio/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile index 6372fd1..ec3efb4 100644 --- a/src/backend/supabase/service/audio/Dockerfile +++ b/src/backend/supabase/service/audio/Dockerfile @@ -22,10 +22,13 @@ RUN apt-get install -y curl # install unzip RUN apt-get install -y unzip -# install deno +# install deno / need to set path ?? RUN wget -qO- https://deno.land/install.sh | sh # install yt-dlp RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp && chmod +x /usr/local/bin/yt-dlp +# Install npm +RUN apt-get install -y npm + CMD ["./main"] \ No newline at end of file From 0f4db863008a7a679d8d34e9bb05b88b6ae795ef Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 23 Feb 2026 22:25:52 +0100 Subject: [PATCH 14/77] reminder --- src/backend/supabase/service/audio/main.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index dbf636c..b71cf09 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -26,6 +26,13 @@ func main() { fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message) + // split off between playlist and single download + + // check if url is a playlist or single video + + // re-think way to handle playlist downloads, + // use old method in mvp where it writes the information to a file and then reads it back to update the database + // Works ish download := FlatSingleDownload("archive.txt", "ids.txt", "names.txt", "duration.txt", "playlist_title.txt", "playlist_id.txt", "https://www.youtube.com/watch?v=s-uEFHxZ_nE", "output.log", "error.log", "", "opus") From e7b4f21927b470a1addc11ad5b643ab8081f4b67 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Tue, 24 Feb 2026 21:33:28 +0100 Subject: [PATCH 15/77] reminder --- src/backend/supabase/service/audio/log.go | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/backend/supabase/service/audio/log.go diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go new file mode 100644 index 0000000..49e25c1 --- /dev/null +++ b/src/backend/supabase/service/audio/log.go @@ -0,0 +1,3 @@ +package main + +// TODO Use database.go to create a logging system for the ytdlp From a33195fdea0b4e4e862c728895abad95ba9befc5 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Wed, 25 Feb 2026 22:21:53 +0100 Subject: [PATCH 16/77] wip, tired --- src/backend/supabase/service/audio/main.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index b71cf09..1b76148 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strings" "time" ) @@ -27,8 +28,15 @@ func main() { fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message) // split off between playlist and single download + sourceUrl := string(audioQueueMessage.Message) + isPlaylist := strings.Contains(sourceUrl, "playlist?") // check if url is a playlist or single video + if isPlaylist { + fmt.Println("Playlist download detected") + } else { + fmt.Println("Single video download detected") + } // re-think way to handle playlist downloads, // use old method in mvp where it writes the information to a file and then reads it back to update the database From 7b1fcbc841db2109471cadc2a3bb9233b6c7e98d Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 26 Feb 2026 21:26:18 +0100 Subject: [PATCH 17/77] base logging --- .../supabase/service/audio/database.go | 36 ++++++++++++++++++ src/backend/supabase/service/audio/log.go | 37 ++++++++++++++++++- src/backend/supabase/service/audio/main.go | 10 +++++ src/backend/supabase/service/audio/models.go | 13 ++++--- .../service/migration/scripts/0 initial.sql | 9 +++-- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/backend/supabase/service/audio/database.go b/src/backend/supabase/service/audio/database.go index 12eebd0..64fed74 100644 --- a/src/backend/supabase/service/audio/database.go +++ b/src/backend/supabase/service/audio/database.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -78,6 +79,41 @@ func (base *BaseTable) InsertWithReturningId(query string, params ...any) (lastI return lastInsertedId, nil } +func (base *BaseTable) InsertWithReturningIdUUID(query string, params ...any) (lastInsertedId uuid.UUID, err error) { + + if !strings.Contains(query, ReturningIdParameter) { + return uuid.Nil, errors.New("Query does not contain RETURNING keyword") + } + + transaction, err := base.Pool.Begin(context.Background()) + if err != nil { + return uuid.Nil, err + } + + statement, err := transaction.Prepare(context.Background(), "", query) + if err != nil { + transaction.Rollback(context.Background()) + return uuid.Nil, err + } + defer transaction.Conn().Close(context.Background()) + + err = transaction.QueryRow(context.Background(), statement.SQL, params...).Scan(&lastInsertedId) + + if err != nil { + transaction.Rollback(context.Background()) + return uuid.Nil, err + } + + err = transaction.Commit(context.Background()) + + if err != nil { + transaction.Rollback(context.Background()) + return uuid.Nil, err + } + + return lastInsertedId, nil +} + func (base *BaseTable) NonScalarQuery(query string, params ...any) (error error) { transaction, err := base.Pool.Begin(context.Background()) diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go index 49e25c1..54442d0 100644 --- a/src/backend/supabase/service/audio/log.go +++ b/src/backend/supabase/service/audio/log.go @@ -1,3 +1,38 @@ package main -// TODO Use database.go to create a logging system for the ytdlp +import "context" + +type IYtdlpLogger interface { + CreateNewLog(title string) (*YtdlpOutputLog, error) + UpdateLog(log *YtdlpOutputLog) error +} + +type YtdlpLogger struct { + IYtdlpLogger + database BaseTable +} + +func NewYtdlpLogger() *YtdlpLogger { + // Will throw an error if its missing a method implementation from interface + // will throw a compile time error + var _ IYtdlpLogger = (*YtdlpLogger)(nil) + + return &YtdlpLogger{} +} + +func (l *YtdlpLogger) CreateNewLog(title string) (*YtdlpOutputLog, error) { + lastinsertedId, err := l.database.InsertWithReturningIdUUID("INSERT INTO YtdlpOutputLog (title, progressState) VALUES ($1, $2) RETURNING id", title, 0) + if err != nil { + return nil, err + } + return &YtdlpOutputLog{ + Id: lastinsertedId, + ProgressState: 0, + Title: &title, + }, nil +} + +func (l *YtdlpLogger) UpdateLog(log *YtdlpOutputLog) error { + _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET title = $1, outputlog = $2, erroroutputlog = $3, progressstate = $4 WHERE id = $5", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.Id) + return err +} diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index 1b76148..0ea9d69 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -18,6 +18,8 @@ func main() { // Create queue listener var queueListener = *createQueueListener() + var logger = NewYtdlpLogger() + for true { audioQueueMessage, _ := listenForMessage(&queueListener) @@ -27,6 +29,14 @@ func main() { fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message) + log, err := logger.CreateNewLog(string(audioQueueMessage.Message)) + + if err != nil { + fmt.Printf("Created log with id: %s\n", log.Id) + } else { + fmt.Errorf("Failed to create log for message id: %d, error: %s\n", audioQueueMessage.Id, err.Error()) + } + // split off between playlist and single download sourceUrl := string(audioQueueMessage.Message) isPlaylist := strings.Contains(sourceUrl, "playlist?") diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go index 67dbabc..a21d2db 100644 --- a/src/backend/supabase/service/audio/models.go +++ b/src/backend/supabase/service/audio/models.go @@ -22,15 +22,16 @@ type Audio struct { SourceName *string `db:"source_name"` // nullable StorageLocation string `db:"storage_location"` ThumbnailLocation *string `db:"thumbnail_location"` // nullable - ProgressState int `db:"progress_state"` DownloadCount int `db:"download_count"` CreatedAtUtc time.Time `db:"created_at_utc"` } type YtdlpOutputLog struct { - Id uuid.UUID `db:"id"` - AudioId uuid.UUID `db:"audio_id"` - Title *string `db:"title"` // nullable - OutputLogBase64 *string `db:"output_log"` // nullable - CreatedAtUtc time.Time `db:"created_at_utc"` + Id uuid.UUID `db:"id"` + AudioId uuid.UUID `db:"audio_id"` + ProgressState int `db:"progress_state"` + Title *string `db:"title"` // nullable + OutputLogBase64 *string `db:"output_log"` // nullable + ErrorOutputLogBase64 *string `db:"error_output_log"` // nullable + CreatedAtUtc time.Time `db:"created_at_utc"` } diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index a273a20..281d428 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -29,16 +29,19 @@ CREATE TABLE IF NOT EXISTS Librebeats.Audio ( SourceName TEXT NOT NULL, StorageLocation TEXT, ThumbnailLocation TEXT, - ProgressState INT NOT NULL, DownloadCount INT NOT NULL DEFAULT 0, CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( Id INT PRIMARY KEY, - AudioId serial NOT NULL, + AudioId serial, Title TEXT NOT NULL, - OutputLogBase64 TEXT NOT NULL, + ProgressState INT NOT NULL, + OutputLogBase64 TEXT, + ErrorOutputLogBase64 TEXT, CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW(), FinishedAtUtc TIMESTAMP ); + + From fc843f1faa3855d618ad48c2a97ce5b7ed0d468b Mon Sep 17 00:00:00 2001 From: Boris Mulder Date: Sun, 8 Mar 2026 00:25:55 +0100 Subject: [PATCH 18/77] thsi took a while --- src/frontend/lib/data/models.dart | 193 ++++++ src/frontend/lib/main.dart | 152 +++-- .../lib/providers/library_provider.dart | 203 ++++++ .../lib/providers/player_provider.dart | 156 +++++ src/frontend/lib/screens/home_screen.dart | 441 +++++++++++++ .../lib/screens/playlist_detail_screen.dart | 190 ++++++ .../lib/screens/playlists_screen.dart | 327 ++++++++++ src/frontend/lib/screens/search_screen.dart | 275 ++++++++ src/frontend/lib/screens/settings_screen.dart | 585 ++++++++++++++++++ src/frontend/lib/theme/app_theme.dart | 87 +++ src/frontend/lib/widgets/player_widgets.dart | 417 +++++++++++++ src/frontend/lib/widgets/shared_widgets.dart | 359 +++++++++++ .../Flutter/GeneratedPluginRegistrant.swift | 8 + src/frontend/pubspec.lock | 423 ++++++++++++- src/frontend/pubspec.yaml | 10 + 15 files changed, 3777 insertions(+), 49 deletions(-) create mode 100644 src/frontend/lib/data/models.dart create mode 100644 src/frontend/lib/providers/library_provider.dart create mode 100644 src/frontend/lib/providers/player_provider.dart create mode 100644 src/frontend/lib/screens/home_screen.dart create mode 100644 src/frontend/lib/screens/playlist_detail_screen.dart create mode 100644 src/frontend/lib/screens/playlists_screen.dart create mode 100644 src/frontend/lib/screens/search_screen.dart create mode 100644 src/frontend/lib/screens/settings_screen.dart create mode 100644 src/frontend/lib/theme/app_theme.dart create mode 100644 src/frontend/lib/widgets/player_widgets.dart create mode 100644 src/frontend/lib/widgets/shared_widgets.dart diff --git a/src/frontend/lib/data/models.dart b/src/frontend/lib/data/models.dart new file mode 100644 index 0000000..2bad84c --- /dev/null +++ b/src/frontend/lib/data/models.dart @@ -0,0 +1,193 @@ +// ─── Song ────────────────────────────────────────────────────────────────── +class Song { + final String id; + final String title; + final String artist; + final String album; + final String? albumArtUrl; + final Duration duration; + final String? streamUrl; + final String? serverId; // null = local + final DateTime? lastPlayed; + + const Song({ + required this.id, + required this.title, + required this.artist, + required this.album, + this.albumArtUrl, + required this.duration, + this.streamUrl, + this.serverId, + this.lastPlayed, + }); + + String get durationString { + final m = duration.inMinutes; + final s = duration.inSeconds % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } + + factory Song.fromJson(Map json) => Song( + id: json['id']?.toString() ?? '', + title: json['title'] ?? 'Unknown', + artist: json['artist'] ?? 'Unknown Artist', + album: json['album'] ?? '', + albumArtUrl: json['coverArt'], + duration: Duration(seconds: json['duration'] ?? 0), + streamUrl: json['streamUrl'], + serverId: json['serverId'], + ); + + Map toJson() => { + 'id': id, + 'title': title, + 'artist': artist, + 'album': album, + 'albumArtUrl': albumArtUrl, + 'duration': duration.inSeconds, + 'streamUrl': streamUrl, + 'serverId': serverId, + }; +} + +// ─── Playlist ────────────────────────────────────────────────────────────── +class Playlist { + final String id; + String name; + String? description; + String? coverArtUrl; + List songs; + final bool isServer; + final String? serverId; + DateTime? lastPlayed; + final DateTime createdAt; + + Playlist({ + required this.id, + required this.name, + this.description, + this.coverArtUrl, + List? songs, + this.isServer = false, + this.serverId, + this.lastPlayed, + DateTime? createdAt, + }) : songs = songs ?? [], + createdAt = createdAt ?? DateTime.now(); + + int get songCount => songs.length; + + factory Playlist.fromJson(Map json) => Playlist( + id: json['id']?.toString() ?? '', + name: json['name'] ?? 'Untitled', + description: json['description'], + coverArtUrl: json['coverArt'], + isServer: json['isServer'] ?? false, + serverId: json['serverId'], + lastPlayed: json['lastPlayed'] != null + ? DateTime.tryParse(json['lastPlayed']) + : null, + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt']) ?? DateTime.now() + : DateTime.now(), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'coverArtUrl': coverArtUrl, + 'isServer': isServer, + 'serverId': serverId, + 'lastPlayed': lastPlayed?.toIso8601String(), + 'createdAt': createdAt.toIso8601String(), + }; +} + +// ─── Server ──────────────────────────────────────────────────────────────── +enum ServerType { subsonic, navidrome, jellyfin, emby } + +enum ServerStatus { unknown, online, offline, error } + +class MusicServer { + final String id; + String name; + String url; + String? username; + String? password; + ServerType type; + ServerStatus status; + int? songCount; + DateTime? lastSynced; + + MusicServer({ + required this.id, + required this.name, + required this.url, + this.username, + this.password, + this.type = ServerType.navidrome, + this.status = ServerStatus.unknown, + this.songCount, + this.lastSynced, + }); + + String get typeLabel { + switch (type) { + case ServerType.navidrome: + return 'Navidrome'; + case ServerType.subsonic: + return 'Subsonic'; + case ServerType.jellyfin: + return 'Jellyfin'; + case ServerType.emby: + return 'Emby'; + } + } + + factory MusicServer.fromJson(Map json) => MusicServer( + id: json['id'] ?? '', + name: json['name'] ?? '', + url: json['url'] ?? '', + username: json['username'], + type: ServerType.values.firstWhere( + (t) => t.name == json['type'], + orElse: () => ServerType.navidrome, + ), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'username': username, + 'type': type.name, + }; +} + +// ─── Search Result ───────────────────────────────────────────────────────── +enum SearchResultType { song, album, artist, playlist } + +class SearchResult { + final SearchResultType type; + final String id; + final String title; + final String subtitle; + final String? imageUrl; + final dynamic data; // Original object + + const SearchResult({ + required this.type, + required this.id, + required this.title, + required this.subtitle, + this.imageUrl, + this.data, + }); +} + +// ─── Player State ────────────────────────────────────────────────────────── +enum RepeatMode { none, one, all } + +enum ShuffleMode { off, on } \ No newline at end of file diff --git a/src/frontend/lib/main.dart b/src/frontend/lib/main.dart index 81046a0..37a7b56 100644 --- a/src/frontend/lib/main.dart +++ b/src/frontend/lib/main.dart @@ -1,74 +1,130 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'providers/player_provider.dart'; +import 'providers/library_provider.dart'; +import 'screens/home_screen.dart'; +import 'screens/search_screen.dart'; +import 'screens/playlists_screen.dart'; +import 'screens/settings_screen.dart'; +import 'widgets/player_widgets.dart'; +import 'theme/app_theme.dart'; void main() { - runApp(const MyApp()); + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: Color(0xFF121212), + systemNavigationBarIconBrightness: Brightness.light, + )); + runApp(const LibreBeatsApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class LibreBeatsApp extends StatelessWidget { + const LibreBeatsApp({super.key}); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'LibreBeats', - theme: ThemeData( - colorScheme: ColorScheme.dark(), - + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => LibraryProvider()), + ChangeNotifierProvider(create: (_) => PlayerProvider()), + ], + child: MaterialApp( + title: 'LibreBeats', + debugShowCheckedModeBanner: false, + theme: LibreBeatsTheme.theme, + home: const MainShell(), ), - home: const MyHomePage(title: 'LibreBeats'), ); } } -class MyHomePage extends StatefulWidget { - - const MyHomePage({super.key, required this.title}); - - final String title; +class MainShell extends StatefulWidget { + const MainShell({super.key}); @override - State createState() => _MyHomePageState(); + State createState() => _MainShellState(); } -class _MyHomePageState extends State { +class _MainShellState extends State { + int _currentIndex = 0; + + final _screens = const [ + HomeScreen(), + SearchScreen(), + PlaylistsScreen(), + SettingsScreen(), + ]; @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - backgroundColor: const Color.fromARGB(17, 0, 0, 0), - title: Center(child: Text(widget.title)), + backgroundColor: LibreBeatsTheme.background, + body: IndexedStack( + index: _currentIndex, + children: _screens, ), - body: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black, - child: Center(child: Text('Hello LibreBeats!', style: TextStyle(color: Colors.white, fontSize: 24))), - ), - bottomNavigationBar: BottomAppBar( - // elevation - elevation: 0, - // transparent - color: const Color.fromARGB(17, 0, 0, 0), - child: SizedBox( - height: 50, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + bottomNavigationBar: Consumer( + builder: (context, player, _) { + return Column( + mainAxisSize: MainAxisSize.min, children: [ - IconButton(onPressed: () => {}, icon: Icon(Icons.search)), - IconButton(onPressed: () => {}, icon: Icon(Icons.home)), - IconButton(onPressed: () => {}, icon: Icon(Icons.settings)), + // Mini player sits above nav bar + if (player.miniPlayerVisible) + GestureDetector( + onTap: () => FullPlayerSheet.show(context), + child: const MiniPlayer(), + ), + + // Navigation bar + Container( + decoration: const BoxDecoration( + color: Color(0xFF121212), + border: Border( + top: BorderSide(color: LibreBeatsTheme.border, width: 0.5), + ), + ), + child: NavigationBar( + backgroundColor: Colors.transparent, + elevation: 0, + selectedIndex: _currentIndex, + onDestinationSelected: (i) => setState(() => _currentIndex = i), + indicatorColor: LibreBeatsTheme.accentDim, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined, color: LibreBeatsTheme.textDim), + selectedIcon: Icon(Icons.home_rounded, color: LibreBeatsTheme.textPrimary), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.search_outlined, color: LibreBeatsTheme.textDim), + selectedIcon: Icon(Icons.search_rounded, color: LibreBeatsTheme.textPrimary), + label: 'Search', + ), + NavigationDestination( + icon: Icon(Icons.library_music_outlined, color: LibreBeatsTheme.textDim), + selectedIcon: Icon(Icons.library_music_rounded, color: LibreBeatsTheme.textPrimary), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined, color: LibreBeatsTheme.textDim), + selectedIcon: Icon(Icons.settings_rounded, color: LibreBeatsTheme.textPrimary), + label: 'Settings', + ), + ], + ), + ), ], - ), - ), - ) + ); + }, + ), ); } -} +} \ No newline at end of file diff --git a/src/frontend/lib/providers/library_provider.dart b/src/frontend/lib/providers/library_provider.dart new file mode 100644 index 0000000..cbd0ba3 --- /dev/null +++ b/src/frontend/lib/providers/library_provider.dart @@ -0,0 +1,203 @@ +// TODO Implement this library.import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:uuid/uuid.dart'; +const _uuid = Uuid(); + +class LibraryProvider extends ChangeNotifier { + // ── State ────────────────────────────────────────────────────────────── + List _playlists = []; + List _servers = []; + Song? _lastPlayedSong; + Playlist? _lastPlayedPlaylist; + int _localStorageUsedMb = 0; + + bool _isLoading = false; + String? _error; + + // Mock data on init + LibraryProvider() { + _loadMockData(); + } + + void _loadMockData() { + final songs = [ + Song(id: 's1', title: 'Midnight Static', artist: 'Neon Void', album: 'Frequencies', duration: const Duration(minutes: 3, seconds: 42)), + Song(id: 's2', title: 'Hollow Frequencies', artist: 'Circuit Breaker', album: 'Phase II', duration: const Duration(minutes: 4, seconds: 15)), + Song(id: 's3', title: 'Solar Drift', artist: 'Pale Mirror', album: 'Dusk', duration: const Duration(minutes: 5, seconds: 1)), + Song(id: 's4', title: 'Subterranean', artist: 'The Hollow', album: 'Depths', duration: const Duration(minutes: 3, seconds: 28)), + Song(id: 's5', title: 'Glass Architecture', artist: 'Neon Void', album: 'Prism', duration: const Duration(minutes: 4, seconds: 55)), + Song(id: 's6', title: 'Rewired', artist: 'Circuit Breaker', album: 'Phase II', duration: const Duration(minutes: 3, seconds: 11)), + Song(id: 's7', title: 'Cascade Protocol', artist: 'The Hollow', album: 'Depths', duration: const Duration(minutes: 4, seconds: 33)), + ]; + + _playlists = [ + Playlist(id: 'p1', name: 'Late Night Drives', songs: songs.sublist(0, 3), lastPlayed: DateTime.now().subtract(const Duration(hours: 1))), + Playlist(id: 'p2', name: 'Focus Mode', songs: songs.sublist(2, 6), lastPlayed: DateTime.now().subtract(const Duration(hours: 3))), + Playlist(id: 'p3', name: 'Indie Picks', songs: songs.sublist(1, 5), isServer: true, serverId: 'srv1', lastPlayed: DateTime.now().subtract(const Duration(hours: 5))), + Playlist(id: 'p4', name: 'Workout Anthems', songs: songs.sublist(3, 7), lastPlayed: DateTime.now().subtract(const Duration(days: 1))), + Playlist(id: 'p5', name: 'Chill Waves', songs: songs.sublist(0, 4), isServer: true, serverId: 'srv2', lastPlayed: DateTime.now().subtract(const Duration(days: 2))), + ]; + + _servers = [ + MusicServer(id: 'srv1', name: 'Navidrome Home', url: 'http://192.168.1.10:4533', username: 'admin', type: ServerType.navidrome, status: ServerStatus.online, songCount: 1204), + MusicServer(id: 'srv2', name: 'Jellyfin Media', url: 'http://media.local:8096', username: 'user', type: ServerType.jellyfin, status: ServerStatus.online, songCount: 3891), + ]; + + _lastPlayedSong = songs.first; + _lastPlayedPlaylist = _playlists.first; + _localStorageUsedMb = 247; + } + + // ── Getters ──────────────────────────────────────────────────────────── + List get playlists => _playlists; + List get userPlaylists => _playlists.where((p) => !p.isServer).toList(); + List get serverPlaylists => _playlists.where((p) => p.isServer).toList(); + List get playlistsByLastPlayed { + final sorted = [..._playlists]; + sorted.sort((a, b) { + if (a.lastPlayed == null && b.lastPlayed == null) return 0; + if (a.lastPlayed == null) return 1; + if (b.lastPlayed == null) return -1; + return b.lastPlayed!.compareTo(a.lastPlayed!); + }); + return sorted; + } + + List get servers => _servers; + Song? get lastPlayedSong => _lastPlayedSong; + Playlist? get lastPlayedPlaylist => _lastPlayedPlaylist; + int get localStorageUsedMb => _localStorageUsedMb; + bool get isLoading => _isLoading; + String? get error => _error; + + // Suggestions: pull from server playlists + random mix + List get suggestions { + final all = _playlists.expand((p) => p.songs).toList(); + all.shuffle(); + return all.take(8).toList(); + } + + // ── Playlist CRUD ────────────────────────────────────────────────────── + void addPlaylist(String name, {String? description}) { + _playlists.add(Playlist( + id: _uuid.v4(), + name: name, + description: description, + )); + notifyListeners(); + } + + void removePlaylist(String id) { + _playlists.removeWhere((p) => p.id == id); + notifyListeners(); + } + + void updatePlaylist(String id, {String? name, String? description}) { + final idx = _playlists.indexWhere((p) => p.id == id); + if (idx == -1) return; + if (name != null) _playlists[idx].name = name; + if (description != null) _playlists[idx].description = description; + notifyListeners(); + } + + void addSongToPlaylist(String playlistId, Song song) { + final idx = _playlists.indexWhere((p) => p.id == playlistId); + if (idx == -1) return; + if (!_playlists[idx].songs.any((s) => s.id == song.id)) { + _playlists[idx].songs.add(song); + notifyListeners(); + } + } + + void removeSongFromPlaylist(String playlistId, String songId) { + final idx = _playlists.indexWhere((p) => p.id == playlistId); + if (idx == -1) return; + _playlists[idx].songs.removeWhere((s) => s.id == songId); + notifyListeners(); + } + + void markPlaylistPlayed(String playlistId) { + final idx = _playlists.indexWhere((p) => p.id == playlistId); + if (idx != -1) { + _playlists[idx].lastPlayed = DateTime.now(); + notifyListeners(); + } + } + + // ── Server Management ────────────────────────────────────────────────── + Future addServer(MusicServer server) async { + _servers.add(server); + notifyListeners(); + await _checkServerStatus(server.id); + } + + void removeServer(String id) { + _servers.removeWhere((s) => s.id == id); + _playlists.removeWhere((p) => p.serverId == id); + notifyListeners(); + } + + Future _checkServerStatus(String serverId) async { + final idx = _servers.indexWhere((s) => s.id == serverId); + if (idx == -1) return; + // Simulate check + await Future.delayed(const Duration(seconds: 1)); + _servers[idx].status = ServerStatus.online; + notifyListeners(); + } + + Future checkAllServers() async { + for (final s in _servers) { + await _checkServerStatus(s.id); + } + } + + // ── Local Storage ────────────────────────────────────────────────────── + Future clearLocalStorage() async { + _isLoading = true; + notifyListeners(); + await Future.delayed(const Duration(seconds: 1)); + _localStorageUsedMb = 0; + _isLoading = false; + notifyListeners(); + } + + // ── Search ───────────────────────────────────────────────────────────── + Future> search(String query) async { + if (query.trim().isEmpty) return []; + await Future.delayed(const Duration(milliseconds: 400)); + + final q = query.toLowerCase(); + final results = []; + + final allSongs = _playlists.expand((p) => p.songs).toSet().toList(); + for (final song in allSongs) { + if (song.title.toLowerCase().contains(q) || + song.artist.toLowerCase().contains(q) || + song.album.toLowerCase().contains(q)) { + results.add(SearchResult( + type: SearchResultType.song, + id: song.id, + title: song.title, + subtitle: '${song.artist} • ${song.album}', + data: song, + )); + } + } + + for (final pl in _playlists) { + if (pl.name.toLowerCase().contains(q)) { + results.add(SearchResult( + type: SearchResultType.playlist, + id: pl.id, + title: pl.name, + subtitle: '${pl.songCount} songs', + data: pl, + )); + } + } + + return results; + } +} \ No newline at end of file diff --git a/src/frontend/lib/providers/player_provider.dart b/src/frontend/lib/providers/player_provider.dart new file mode 100644 index 0000000..3dbb277 --- /dev/null +++ b/src/frontend/lib/providers/player_provider.dart @@ -0,0 +1,156 @@ +// TODO Implement this library.import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; + +class PlayerProvider extends ChangeNotifier { + Song? _currentSong; + Playlist? _currentPlaylist; + List _queue = []; + int _queueIndex = 0; + + bool _isPlaying = false; + bool _isLoading = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + double _volume = 1.0; + RepeatMode _repeat = RepeatMode.none; + ShuffleMode _shuffle = ShuffleMode.off; + + bool _miniPlayerVisible = false; + bool _fullPlayerVisible = false; + + // Getters + Song? get currentSong => _currentSong; + Playlist? get currentPlaylist => _currentPlaylist; + List get queue => _queue; + bool get isPlaying => _isPlaying; + bool get isLoading => _isLoading; + Duration get position => _position; + Duration get duration => _duration; + double get volume => _volume; + RepeatMode get repeat => _repeat; + ShuffleMode get shuffle => _shuffle; + bool get miniPlayerVisible => _miniPlayerVisible; + bool get fullPlayerVisible => _fullPlayerVisible; + double get progress => _duration.inSeconds > 0 + ? _position.inSeconds / _duration.inSeconds + : 0.0; + + // Play a song + Future playSong(Song song, {Playlist? playlist, List? queue}) async { + _currentSong = song; + _currentPlaylist = playlist; + if (queue != null) { + _queue = queue; + _queueIndex = queue.indexWhere((s) => s.id == song.id); + } else { + _queue = [song]; + _queueIndex = 0; + } + _isPlaying = true; + _isLoading = true; + _miniPlayerVisible = true; + _position = Duration.zero; + _duration = song.duration; + notifyListeners(); + + // Simulate loading + await Future.delayed(const Duration(milliseconds: 500)); + _isLoading = false; + notifyListeners(); + + // Simulate playback progress + _simulatePlayback(); + } + + void _simulatePlayback() async { + while (_isPlaying && _currentSong != null) { + await Future.delayed(const Duration(seconds: 1)); + if (_isPlaying) { + _position += const Duration(seconds: 1); + if (_position >= _duration) { + await skipNext(); + return; + } + notifyListeners(); + } + } + } + + void togglePlayPause() { + _isPlaying = !_isPlaying; + if (_isPlaying) _simulatePlayback(); + notifyListeners(); + } + + Future skipNext() async { + if (_queue.isEmpty) return; + if (_repeat == RepeatMode.one) { + await playSong(_currentSong!, playlist: _currentPlaylist, queue: _queue); + return; + } + int next = _queueIndex + 1; + if (next >= _queue.length) { + if (_repeat == RepeatMode.all) { + next = 0; + } else { + _isPlaying = false; + notifyListeners(); + return; + } + } + _queueIndex = next; + await playSong(_queue[next], playlist: _currentPlaylist, queue: _queue); + } + + Future skipPrev() async { + if (_position.inSeconds > 3) { + _position = Duration.zero; + notifyListeners(); + return; + } + if (_queue.isEmpty) return; + int prev = _queueIndex - 1; + if (prev < 0) prev = _queue.length - 1; + _queueIndex = prev; + await playSong(_queue[prev], playlist: _currentPlaylist, queue: _queue); + } + + void seek(double ratio) { + _position = Duration(seconds: (_duration.inSeconds * ratio).round()); + notifyListeners(); + } + + void setVolume(double v) { + _volume = v.clamp(0.0, 1.0); + notifyListeners(); + } + + void toggleRepeat() { + _repeat = RepeatMode.values[(_repeat.index + 1) % RepeatMode.values.length]; + notifyListeners(); + } + + void toggleShuffle() { + _shuffle = _shuffle == ShuffleMode.off ? ShuffleMode.on : ShuffleMode.off; + notifyListeners(); + } + + void showFullPlayer() { + _fullPlayerVisible = true; + notifyListeners(); + } + + void hideFullPlayer() { + _fullPlayerVisible = false; + notifyListeners(); + } + + void stopAndHide() { + _isPlaying = false; + _miniPlayerVisible = false; + _fullPlayerVisible = false; + _currentSong = null; + notifyListeners(); + } +} \ No newline at end of file diff --git a/src/frontend/lib/screens/home_screen.dart b/src/frontend/lib/screens/home_screen.dart new file mode 100644 index 0000000..41fa312 --- /dev/null +++ b/src/frontend/lib/screens/home_screen.dart @@ -0,0 +1,441 @@ +// TODO Implement this library.import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../providers/player_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, library, player, _) { + return CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + floating: true, + pinned: false, + backgroundColor: LibreBeatsTheme.background, + title: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: LibreBeatsTheme.accent, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.music_note_rounded, color: Colors.white, size: 16), + ), + const SizedBox(width: 10), + const Text('LibreBeats', + style: TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + letterSpacing: -0.5)), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.notifications_none_rounded, color: LibreBeatsTheme.textSecondary), + onPressed: () {}, + ), + const CircleAvatar( + radius: 16, + backgroundColor: LibreBeatsTheme.accent, + child: Text('U', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700)), + ), + const SizedBox(width: 8), + ], + ), + + // Quick Filter Chips + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + _FilterChip(label: 'All', selected: true), + _FilterChip(label: 'Music'), + _FilterChip(label: 'Podcasts'), + _FilterChip(label: 'Mixes'), + ], + ), + ), + ), + + // Last Played Banner + if (library.lastPlayedSong != null) + SliverToBoxAdapter( + child: _LastPlayedBanner( + song: library.lastPlayedSong!, + playlist: library.lastPlayedPlaylist, + onTap: () => player.playSong( + library.lastPlayedSong!, + playlist: library.lastPlayedPlaylist, + ), + ), + ), + + // Recently Played Playlists (grid) + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Recently Played'), + _RecentGrid(library: library, player: player), + ], + ), + ), + + // Your Playlists + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Your Playlists', actionLabel: 'See all'), + SizedBox( + height: 190, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: library.userPlaylists.length, + separatorBuilder: (_, __) => const SizedBox(width: 14), + itemBuilder: (_, i) { + final pl = library.userPlaylists[i]; + return PlaylistCard( + playlist: pl, + onTap: () {}, + ); + }, + ), + ), + ], + ), + ), + + // Server Playlists + if (library.serverPlaylists.isNotEmpty) + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'From Your Servers'), + SizedBox( + height: 190, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: library.serverPlaylists.length, + separatorBuilder: (_, __) => const SizedBox(width: 14), + itemBuilder: (_, i) { + final pl = library.serverPlaylists[i]; + return PlaylistCard(playlist: pl, onTap: () {}); + }, + ), + ), + ], + ), + ), + + // Suggestions (from server mix) + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader( + title: 'Suggested', + actionLabel: 'Refresh', + ), + _SuggestionNote(), + ...library.suggestions.take(4).map((song) => SongTile( + song: song, + onTap: () => player.playSong(song), + onMore: () => _showSongMenu(context, song, library), + )), + const SizedBox(height: 100), + ], + ), + ), + ], + ); + }, + ); + } + + void _showSongMenu(BuildContext context, song, LibraryProvider library) { + showModalBottomSheet( + context: context, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add, color: LibreBeatsTheme.textSecondary), + title: const Text('Add to playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () { + Navigator.pop(context); + _showAddToPlaylist(context, song, library); + }, + ), + ListTile( + leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary), + title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + void _showAddToPlaylist(BuildContext context, song, LibraryProvider library) { + showModalBottomSheet( + context: context, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Add to playlist', + style: TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + ...library.userPlaylists.map((pl) => ListTile( + leading: const Icon(Icons.queue_music, color: LibreBeatsTheme.textSecondary), + title: Text(pl.name, style: const TextStyle(color: LibreBeatsTheme.textPrimary)), + subtitle: Text('${pl.songCount} songs', style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 11)), + onTap: () { + library.addSongToPlaylist(pl.id, song); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added to ${pl.name}'), + backgroundColor: LibreBeatsTheme.surface, + behavior: SnackBarBehavior.floating, + ), + ); + }, + )), + ], + ), + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + const _FilterChip({required this.label, this.selected = false}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary, + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ); + } +} + +class _LastPlayedBanner extends StatelessWidget { + final song; + final playlist; + final VoidCallback onTap; + const _LastPlayedBanner({required this.song, this.playlist, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + LibreBeatsTheme.accent.withOpacity(0.25), + LibreBeatsTheme.accent.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: LibreBeatsTheme.accent.withOpacity(0.3)), + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: LibreBeatsTheme.accent.withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Center(child: Text('🎵', style: TextStyle(fontSize: 26))), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('LAST PLAYED', + style: TextStyle( + color: LibreBeatsTheme.accent, + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.8)), + const SizedBox(height: 2), + Text(song.title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(song.artist, + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12)), + ], + ), + ), + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: LibreBeatsTheme.accent, + shape: BoxShape.circle, + ), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 22), + ), + ], + ), + ), + ); + } +} + +class _RecentGrid extends StatelessWidget { + final LibraryProvider library; + final PlayerProvider player; + const _RecentGrid({required this.library, required this.player}); + + @override + Widget build(BuildContext context) { + final recent = library.playlistsByLastPlayed.take(4).toList(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3.2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: recent.length, + itemBuilder: (_, i) { + final pl = recent[i]; + return GestureDetector( + onTap: () { + if (pl.songs.isNotEmpty) { + player.playSong(pl.songs.first, playlist: pl, queue: pl.songs); + } + }, + child: Container( + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + width: 48, + decoration: BoxDecoration( + color: pl.isServer + ? const Color(0xFF1A2A3A) + : const Color(0xFF2A1A1A), + borderRadius: const BorderRadius.horizontal(left: Radius.circular(8)), + ), + child: Center( + child: Text(pl.isServer ? '📡' : '🎵', + style: const TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text(pl.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _SuggestionNote extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: LibreBeatsTheme.border), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, color: LibreBeatsTheme.textSecondary, size: 15), + SizedBox(width: 8), + Expanded( + child: Text( + 'Suggestions are pulled from your connected server libraries and local playlists.', + style: TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 11), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/src/frontend/lib/screens/playlist_detail_screen.dart b/src/frontend/lib/screens/playlist_detail_screen.dart new file mode 100644 index 0000000..454a8b8 --- /dev/null +++ b/src/frontend/lib/screens/playlist_detail_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../providers/player_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; + +class PlaylistDetailScreen extends StatelessWidget { + final Playlist playlist; + const PlaylistDetailScreen({super.key, required this.playlist}); + + @override + Widget build(BuildContext context) { + final player = context.read(); + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 280, + pinned: true, + backgroundColor: LibreBeatsTheme.background, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: playlist.isServer + ? [const Color(0xFF1A2A3A), LibreBeatsTheme.background] + : [const Color(0xFF3A1A1A), LibreBeatsTheme.background], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 60), + Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: LibreBeatsTheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 30, + offset: const Offset(0, 10)) + ], + ), + child: Center( + child: Text(playlist.isServer ? '📡' : '🎵', + style: const TextStyle(fontSize: 60)), + ), + ), + const SizedBox(height: 14), + Text(playlist.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800)), + Text('${playlist.songCount} songs', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 13)), + ], + ), + ], + ), + ), + ), + + // Play controls + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: AccentButton( + label: 'Play', + icon: Icons.play_arrow_rounded, + onTap: playlist.songs.isEmpty + ? null + : () => player.playSong( + playlist.songs.first, + playlist: playlist, + queue: playlist.songs, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: AccentButton( + label: 'Shuffle', + icon: Icons.shuffle_rounded, + outlined: true, + onTap: playlist.songs.isEmpty + ? null + : () { + final shuffled = [...playlist.songs]..shuffle(); + player.playSong(shuffled.first, + playlist: playlist, queue: shuffled); + }, + ), + ), + ], + ), + ), + ), + + // Song list + if (playlist.songs.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(40), + child: Column( + children: [ + Icon(Icons.queue_music, color: LibreBeatsTheme.textDim, size: 48), + SizedBox(height: 12), + Text('No songs yet', + style: TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 15)), + SizedBox(height: 6), + Text('Search for songs and add them here.', + style: TextStyle(color: LibreBeatsTheme.textDim, fontSize: 13)), + ], + ), + ), + ), + + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final song = playlist.songs[i]; + return SongTile( + song: song, + onTap: () => player.playSong(song, playlist: playlist, queue: playlist.songs), + onMore: () => _showSongOptions(context, song), + ); + }, + childCount: playlist.songs.length, + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ), + ); + } + + void _showSongOptions(BuildContext context, Song song) { + final library = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!playlist.isServer) + ListTile( + leading: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 20), + title: const Text('Remove from playlist', style: TextStyle(color: Colors.red)), + onTap: () { + library.removeSongFromPlaylist(playlist.id, song.id); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.playlist_add, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Add to another playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () => Navigator.pop(context), + ), + ListTile( + leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/src/frontend/lib/screens/playlists_screen.dart b/src/frontend/lib/screens/playlists_screen.dart new file mode 100644 index 0000000..07e9ece --- /dev/null +++ b/src/frontend/lib/screens/playlists_screen.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; +import 'playlist_detail_screen.dart'; + +class PlaylistsScreen extends StatelessWidget { + const PlaylistsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, library, _) { + final playlists = library.playlistsByLastPlayed; + return CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + backgroundColor: LibreBeatsTheme.background, + title: const Text('Library'), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: LibreBeatsTheme.textSecondary), + onPressed: () => _showCreateDialog(context, library), + ), + IconButton( + icon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary), + onPressed: () {}, + ), + ], + ), + + // Sort tabs + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + _SortChip(label: 'Recently played', selected: true), + _SortChip(label: 'A–Z'), + _SortChip(label: 'My playlists'), + _SortChip(label: 'Server'), + ], + ), + ), + ), + + // Playlist list + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final pl = playlists[i]; + return _PlaylistTile( + playlist: pl, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlaylistDetailScreen(playlist: pl), + ), + ), + onDelete: () => _confirmDelete(context, library, pl), + onEdit: () => _showEditDialog(context, library, pl), + ); + }, + childCount: playlists.length, + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ); + }, + ); + } + + void _showCreateDialog(BuildContext context, LibraryProvider library) { + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('New Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Playlist name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + if (ctrl.text.isNotEmpty) { + library.addPlaylist(ctrl.text); + Navigator.pop(context); + } + }, + child: const Text('Create', style: TextStyle(color: LibreBeatsTheme.accent)), + ), + ], + ), + ); + } + + void _showEditDialog(BuildContext context, LibraryProvider library, Playlist pl) { + final ctrl = TextEditingController(text: pl.name); + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Edit Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Playlist name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + library.updatePlaylist(pl.id, name: ctrl.text); + Navigator.pop(context); + }, + child: const Text('Save', style: TextStyle(color: LibreBeatsTheme.accent)), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context, LibraryProvider library, Playlist pl) { + if (pl.isServer) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete server playlists'), + backgroundColor: LibreBeatsTheme.surface, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Delete Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: Text('Remove "${pl.name}"? This cannot be undone.', + style: const TextStyle(color: LibreBeatsTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + library.removePlaylist(pl.id); + Navigator.pop(context); + }, + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} + +class _PlaylistTile extends StatelessWidget { + final Playlist playlist; + final VoidCallback onTap; + final VoidCallback onDelete; + final VoidCallback onEdit; + + const _PlaylistTile({ + required this.playlist, + required this.onTap, + required this.onDelete, + required this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Cover + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + colors: playlist.isServer + ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)] + : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text(playlist.isServer ? '📡' : '🎵', + style: const TextStyle(fontSize: 26)), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(playlist.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + if (playlist.isServer) + ChipTag(label: 'SERVER', color: const Color(0xFF1DB954)), + ], + ), + const SizedBox(height: 3), + Text( + '${playlist.songCount} songs${playlist.lastPlayed != null ? " • " + _timeAgo(playlist.lastPlayed!) : ""}', + style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, size: 18), + color: LibreBeatsTheme.textSecondary, + splashRadius: 20, + onPressed: () => _showMenu(context), + ), + ], + ), + ), + ); + } + + void _showMenu(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!playlist.isServer) ...[ + ListTile( + leading: const Icon(Icons.edit, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Edit', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () { Navigator.pop(context); onEdit(); }, + ), + ListTile( + leading: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + title: const Text('Delete', style: TextStyle(color: Colors.red)), + onTap: () { Navigator.pop(context); onDelete(); }, + ), + ], + ListTile( + leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + String _timeAgo(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } +} + +class _SortChip extends StatelessWidget { + final String label; + final bool selected; + const _SortChip({required this.label, this.selected = false}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary, + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ); + } +} \ No newline at end of file diff --git a/src/frontend/lib/screens/search_screen.dart b/src/frontend/lib/screens/search_screen.dart new file mode 100644 index 0000000..6d69e64 --- /dev/null +++ b/src/frontend/lib/screens/search_screen.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../providers/player_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; + +class SearchScreen extends StatefulWidget { + const SearchScreen({super.key}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + final _controller = TextEditingController(); + List _results = []; + bool _loading = false; + Timer? _debounce; + String _lastQuery = ''; + + @override + void dispose() { + _controller.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onChanged(String q) { + _debounce?.cancel(); + if (q.isEmpty) { + setState(() { _results = []; _loading = false; }); + return; + } + setState(() => _loading = true); + _debounce = Timer(const Duration(milliseconds: 350), () async { + final library = context.read(); + final res = await library.search(q); + if (mounted) setState(() { _results = res; _loading = false; _lastQuery = q; }); + }); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + backgroundColor: LibreBeatsTheme.background, + title: const Text('Search'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TextField( + controller: _controller, + onChanged: _onChanged, + autofocus: false, + style: const TextStyle(color: LibreBeatsTheme.textPrimary, fontSize: 15), + decoration: InputDecoration( + hintText: 'Songs, artists, albums...', + prefixIcon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary, size: 20), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, size: 18, color: LibreBeatsTheme.textSecondary), + onPressed: () { + _controller.clear(); + _onChanged(''); + }, + ) + : null, + ), + ), + ), + ), + ), + + if (_controller.text.isEmpty) + const SliverToBoxAdapter(child: _BrowseCategories()), + + if (_loading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator(color: LibreBeatsTheme.accent, strokeWidth: 2)), + ), + ), + + if (!_loading && _controller.text.isNotEmpty && _results.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + children: [ + const Icon(Icons.search_off, color: LibreBeatsTheme.textDim, size: 48), + const SizedBox(height: 12), + Text('No results for "$_lastQuery"', + style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 15)), + ], + ), + ), + ), + + if (!_loading && _results.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('${_results.length} results for "$_lastQuery"', + style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 13)), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => _ResultTile(result: _results[i]), + childCount: _results.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ], + ); + } +} + +class _ResultTile extends StatelessWidget { + final SearchResult result; + const _ResultTile({required this.result}); + + @override + Widget build(BuildContext context) { + final player = context.read(); + final icon = result.type == SearchResultType.song + ? Icons.music_note_rounded + : result.type == SearchResultType.playlist + ? Icons.queue_music_rounded + : result.type == SearchResultType.artist + ? Icons.person_rounded + : Icons.album_rounded; + + return InkWell( + onTap: () { + if (result.type == SearchResultType.song && result.data is Song) { + player.playSong(result.data as Song); + } + // TODO: navigate to playlist/album/artist + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular( + result.type == SearchResultType.artist ? 24 : 8), + ), + child: Icon(icon, color: LibreBeatsTheme.textSecondary, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(result.title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Row( + children: [ + ChipTag( + label: result.type.name.toUpperCase(), + color: result.type == SearchResultType.song + ? LibreBeatsTheme.accent + : result.type == SearchResultType.playlist + ? const Color(0xFF1DB954) + : LibreBeatsTheme.textSecondary, + ), + const SizedBox(width: 6), + Expanded( + child: Text(result.subtitle, + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ), + ), + if (result.type == SearchResultType.song) + IconButton( + icon: const Icon(Icons.more_vert, size: 18), + color: LibreBeatsTheme.textSecondary, + onPressed: () {}, + splashRadius: 20, + ), + ], + ), + ), + ); + } +} + +class _BrowseCategories extends StatelessWidget { + const _BrowseCategories(); + + static const _cats = [ + ('Trending', '🔥', Color(0xFF8B1A1A)), + ('Electronic', '⚡', Color(0xFF1A3A5A)), + ('Indie', '🎸', Color(0xFF3A2A1A)), + ('Focus', '🎯', Color(0xFF1A3A2A)), + ('Hip-Hop', '🎤', Color(0xFF2A1A3A)), + ('Ambient', '🌊', Color(0xFF1A2A3A)), + ('Rock', '🤘', Color(0xFF3A1A1A)), + ('Jazz', '🎷', Color(0xFF2A3A1A)), + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Browse by genre'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 2.0, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: _cats.length, + itemBuilder: (_, i) { + final (name, emoji, color) = _cats[i]; + return GestureDetector( + onTap: () {}, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 24)), + const SizedBox(width: 10), + Text(name, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700)), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 100), + ], + ); + } +} \ No newline at end of file diff --git a/src/frontend/lib/screens/settings_screen.dart b/src/frontend/lib/screens/settings_screen.dart new file mode 100644 index 0000000..530dcbe --- /dev/null +++ b/src/frontend/lib/screens/settings_screen.dart @@ -0,0 +1,585 @@ +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; +import '../providers/library_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; + +const _uuid = Uuid(); + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, library, _) { + return CustomScrollView( + slivers: [ + const SliverAppBar( + floating: true, + pinned: true, + backgroundColor: LibreBeatsTheme.background, + title: Text('Settings'), + ), + + // ── Servers ────────────────────────────────────────────────── + const SliverToBoxAdapter(child: SectionHeader(title: 'Music Servers')), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + ...library.servers.map((s) => _ServerCard( + server: s, + onRemove: () => _confirmRemoveServer(context, library, s), + onTest: () async { + await library.checkAllServers(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${s.name}: ${s.status.name}'), + backgroundColor: LibreBeatsTheme.surface, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + )), + const SizedBox(height: 10), + _AddButton( + label: 'Add Server', + icon: Icons.add, + onTap: () => _showAddServerSheet(context, library), + ), + ], + ), + ), + ), + + // ── Local Storage ───────────────────────────────────────────── + const SliverToBoxAdapter(child: SectionHeader(title: 'Local Storage')), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _StorageCard(library: library), + ], + ), + ), + ), + + // ── App ─────────────────────────────────────────────────────── + const SliverToBoxAdapter(child: SectionHeader(title: 'App')), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _SettingsTile( + icon: Icons.info_outline, + title: 'About LibreBeats', + subtitle: 'Version 1.0.0', + onTap: () => _showAbout(context), + ), + _SettingsTile( + icon: Icons.code, + title: 'Open Source Licenses', + onTap: () => showLicensePage(context: context), + ), + ], + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ); + }, + ); + } + + void _confirmRemoveServer(BuildContext context, LibraryProvider library, MusicServer s) { + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Remove Server', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: Text('Remove "${s.name}"? Server playlists will also be removed.', + style: const TextStyle(color: LibreBeatsTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + library.removeServer(s.id); + Navigator.pop(context); + }, + child: const Text('Remove', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + void _showAddServerSheet(BuildContext context, LibraryProvider library) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (_) => _AddServerForm(library: library), + ); + } + + void _showAbout(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: LibreBeatsTheme.accent, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.music_note_rounded, color: Colors.white, size: 18), + ), + const SizedBox(width: 10), + const Text('LibreBeats', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + ], + ), + content: const Text( + 'A free, open-source music player for Subsonic-compatible servers and local libraries.\n\nVersion 1.0.0', + style: TextStyle(color: LibreBeatsTheme.textSecondary, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close', style: TextStyle(color: LibreBeatsTheme.accent)), + ), + ], + ), + ); + } +} + +// ── Server Card ──────────────────────────────────────────────────────────── +class _ServerCard extends StatelessWidget { + final MusicServer server; + final VoidCallback onRemove; + final VoidCallback onTest; + + const _ServerCard({required this.server, required this.onRemove, required this.onTest}); + + @override + Widget build(BuildContext context) { + final isOnline = server.status == ServerStatus.online; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LibreBeatsTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF1A2A3A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.dns_rounded, color: Color(0xFF4A9FD4), size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(server.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700)), + Text(server.url, + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isOnline ? LibreBeatsTheme.online : LibreBeatsTheme.offline, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + color: isOnline ? LibreBeatsTheme.online : LibreBeatsTheme.offline, + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ], + ), + if (server.songCount != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + ChipTag(label: server.typeLabel, color: const Color(0xFF4A9FD4)), + const SizedBox(width: 8), + Text('${server.songCount} songs', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 11)), + ], + ), + ], + const SizedBox(height: 10), + const Divider(height: 1), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextButton.icon( + onPressed: onTest, + icon: const Icon(Icons.wifi_find_rounded, size: 15), + label: const Text('Test', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: LibreBeatsTheme.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 6), + ), + ), + ), + Expanded( + child: TextButton.icon( + onPressed: onRemove, + icon: const Icon(Icons.delete_outline, size: 15), + label: const Text('Remove', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 6), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Storage Card ─────────────────────────────────────────────────────────── +class _StorageCard extends StatelessWidget { + final LibraryProvider library; + const _StorageCard({required this.library}); + + @override + Widget build(BuildContext context) { + final used = library.localStorageUsedMb; + final total = 512; + final ratio = (used / total).clamp(0.0, 1.0); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LibreBeatsTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Cached Audio', + style: TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600)), + Text('${used}MB / ${total}MB', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12)), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ratio, + minHeight: 6, + backgroundColor: LibreBeatsTheme.border, + valueColor: AlwaysStoppedAnimation( + ratio > 0.8 ? Colors.orange : LibreBeatsTheme.accent, + ), + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: library.isLoading + ? null + : () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Clear Cache', + style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: const Text( + 'This will delete all cached audio. You will need an internet connection to stream again.', + style: TextStyle(color: LibreBeatsTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel', + style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear', + style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirm == true) await library.clearLocalStorage(); + }, + icon: library.isLoading + ? const SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.delete_sweep_rounded, size: 16), + label: Text(library.isLoading ? 'Clearing...' : 'Clear Cache'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2A1A1A), + foregroundColor: Colors.red, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + ); + } +} + +// ── Add Server Form ──────────────────────────────────────────────────────── +class _AddServerForm extends StatefulWidget { + final LibraryProvider library; + const _AddServerForm({required this.library}); + + @override + State<_AddServerForm> createState() => _AddServerFormState(); +} + +class _AddServerFormState extends State<_AddServerForm> { + final _name = TextEditingController(); + final _url = TextEditingController(); + final _user = TextEditingController(); + final _pass = TextEditingController(); + ServerType _type = ServerType.navidrome; + bool _obscure = true; + bool _saving = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + 20, 20, 20, MediaQuery.of(context).viewInsets.bottom + 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Add Server', + style: TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700)), + const SizedBox(height: 16), + + // Type selector + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: ServerType.values.map((t) { + final sel = t == _type; + return GestureDetector( + onTap: () => setState(() => _type = t), + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: sel ? LibreBeatsTheme.accent : LibreBeatsTheme.surfaceVariant, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + t.name[0].toUpperCase() + t.name.substring(1), + style: TextStyle( + color: sel ? Colors.white : LibreBeatsTheme.textSecondary, + fontSize: 12, + fontWeight: sel ? FontWeight.w700 : FontWeight.w500), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 14), + + TextField(controller: _name, style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Server name')), + const SizedBox(height: 10), + TextField(controller: _url, style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'URL (http://...)'), + keyboardType: TextInputType.url), + const SizedBox(height: 10), + TextField(controller: _user, style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Username')), + const SizedBox(height: 10), + TextField( + controller: _pass, + obscureText: _obscure, + style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: InputDecoration( + hintText: 'Password', + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility, + color: LibreBeatsTheme.textSecondary, size: 18), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: LibreBeatsTheme.accent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _saving + ? const SizedBox(width: 18, height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Connect', style: TextStyle(fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ); + } + + Future _save() async { + if (_name.text.isEmpty || _url.text.isEmpty) return; + setState(() => _saving = true); + final server = MusicServer( + id: _uuid.v4(), + name: _name.text, + url: _url.text, + username: _user.text, + password: _pass.text, + type: _type, + ); + await widget.library.addServer(server); + if (mounted) Navigator.pop(context); + } +} + +// ── Shared Small Widgets ─────────────────────────────────────────────────── +class _AddButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + const _AddButton({required this.label, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LibreBeatsTheme.border, style: BorderStyle.solid), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: LibreBeatsTheme.accent, size: 18), + const SizedBox(width: 8), + Text(label, + style: const TextStyle( + color: LibreBeatsTheme.accent, + fontSize: 14, + fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} + +class _SettingsTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + const _SettingsTile({required this.icon, required this.title, this.subtitle, this.onTap}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LibreBeatsTheme.border), + ), + child: ListTile( + leading: Icon(icon, color: LibreBeatsTheme.textSecondary, size: 20), + title: Text(title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, fontSize: 14, fontWeight: FontWeight.w500)), + subtitle: subtitle != null + ? Text(subtitle!, style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12)) + : null, + trailing: const Icon(Icons.chevron_right, color: LibreBeatsTheme.textDim, size: 18), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + } +} \ No newline at end of file diff --git a/src/frontend/lib/theme/app_theme.dart b/src/frontend/lib/theme/app_theme.dart new file mode 100644 index 0000000..dda7807 --- /dev/null +++ b/src/frontend/lib/theme/app_theme.dart @@ -0,0 +1,87 @@ +// TODO Implement this library.import 'package:flutter/material.dart'; + + +import 'package:flutter/material.dart'; + +class LibreBeatsTheme { + // Core palette + static const Color background = Color(0xFF0F0F0F); + static const Color surface = Color(0xFF1A1A1A); + static const Color surfaceVariant = Color(0xFF242424); + static const Color card = Color(0xFF181818); + static const Color border = Color(0xFF2A2A2A); + + // Accent + static const Color accent = Color(0xFFFF4D4D); + static const Color accentDim = Color(0x22FF4D4D); + static const Color accentGlow = Color(0x55FF4D4D); + + // Text + static const Color textPrimary = Color(0xFFE8E8E8); + static const Color textSecondary = Color(0xFF888888); + static const Color textDim = Color(0xFF555555); + + // Status + static const Color online = Color(0xFF1DB954); + static const Color offline = Color(0xFF888888); + + static ThemeData get theme => ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: background, + colorScheme: const ColorScheme.dark( + primary: accent, + secondary: accent, + surface: surface, + onPrimary: Colors.white, + onSurface: textPrimary, + ), + appBarTheme: const AppBarTheme( + backgroundColor: background, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: textPrimary, + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + iconTheme: IconThemeData(color: textSecondary), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Color(0xFF121212), + selectedItemColor: textPrimary, + unselectedItemColor: textDim, + type: BottomNavigationBarType.fixed, + elevation: 0, + selectedLabelStyle: TextStyle(fontSize: 10, fontWeight: FontWeight.w600), + unselectedLabelStyle: TextStyle(fontSize: 10), + ), + cardTheme: CardThemeData( + color: card, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + hintStyle: const TextStyle(color: textSecondary, fontSize: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + textTheme: const TextTheme( + displayLarge: TextStyle(color: textPrimary, fontWeight: FontWeight.w800), + headlineMedium: TextStyle(color: textPrimary, fontWeight: FontWeight.w700, fontSize: 22), + titleLarge: TextStyle(color: textPrimary, fontWeight: FontWeight.w700, fontSize: 16), + titleMedium: TextStyle(color: textPrimary, fontWeight: FontWeight.w600, fontSize: 14), + bodyMedium: TextStyle(color: textSecondary, fontSize: 13), + labelSmall: TextStyle(color: textDim, fontSize: 11), + ), + dividerTheme: const DividerThemeData(color: border, thickness: 1), + iconTheme: const IconThemeData(color: textSecondary), + splashColor: accentDim, + highlightColor: Colors.transparent, + ); +} \ No newline at end of file diff --git a/src/frontend/lib/widgets/player_widgets.dart b/src/frontend/lib/widgets/player_widgets.dart new file mode 100644 index 0000000..944de65 --- /dev/null +++ b/src/frontend/lib/widgets/player_widgets.dart @@ -0,0 +1,417 @@ +// TODO Implement this library.import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import '../providers/player_provider.dart'; +import '../theme/app_theme.dart'; +import 'shared_widgets.dart'; + +// ── Mini Player ──────────────────────────────────────────────────────────── +class MiniPlayer extends StatelessWidget { + const MiniPlayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, player, _) { + if (!player.miniPlayerVisible || player.currentSong == null) { + return const SizedBox.shrink(); + } + final song = player.currentSong!; + return GestureDetector( + onTap: player.showFullPlayer, + child: Container( + margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: LibreBeatsTheme.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Progress bar at top + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), + child: LinearProgressIndicator( + value: player.progress, + backgroundColor: LibreBeatsTheme.border, + valueColor: const AlwaysStoppedAnimation(LibreBeatsTheme.accent), + minHeight: 2, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + AlbumArt(size: 40, radius: 8), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(song.title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(song.artist, + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, + fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + _MiniBtn( + icon: Icons.skip_previous_rounded, + onTap: player.skipPrev, + ), + _MiniBtn( + icon: player.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + size: 32, + onTap: player.togglePlayPause, + color: LibreBeatsTheme.textPrimary, + ), + _MiniBtn( + icon: Icons.skip_next_rounded, + onTap: player.skipNext, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _MiniBtn extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + final double size; + final Color? color; + + const _MiniBtn({ + required this.icon, + this.onTap, + this.size = 22, + this.color, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon, size: size, color: color ?? LibreBeatsTheme.textSecondary), + onPressed: onTap, + splashRadius: 20, + padding: const EdgeInsets.symmetric(horizontal: 4), + constraints: const BoxConstraints(), + ); + } +} + +// ── Full Player Screen ───────────────────────────────────────────────────── +class FullPlayerSheet extends StatefulWidget { + const FullPlayerSheet({super.key}); + + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const FullPlayerSheet(), + ); + } + + @override + State createState() => _FullPlayerSheetState(); +} + +class _FullPlayerSheetState extends State { + bool _liked = false; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, player, _) { + final song = player.currentSong; + if (song == null) return const SizedBox.shrink(); + + return Container( + height: MediaQuery.of(context).size.height * 0.92, + decoration: const BoxDecoration( + color: Color(0xFF141414), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: LibreBeatsTheme.border, + borderRadius: BorderRadius.circular(2), + ), + ), + // Top bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 28), + color: LibreBeatsTheme.textSecondary, + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + player.currentPlaylist?.name ?? 'Now Playing', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, size: 22), + color: LibreBeatsTheme.textSecondary, + onPressed: () {}, + ), + ], + ), + ), + + // Album Art (large) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: const LinearGradient( + colors: [Color(0xFF2A1A1A), Color(0xFF0D0808)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: LibreBeatsTheme.accent.withOpacity(0.2), + blurRadius: 40, + offset: const Offset(0, 20), + ), + ], + ), + child: const Center( + child: Text('🎵', style: TextStyle(fontSize: 80)), + ), + ), + ), + ), + ), + + // Song info + like + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(song.title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.3)), + const SizedBox(height: 4), + Text(song.artist, + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, + fontSize: 15)), + ], + ), + ), + GestureDetector( + onTap: () => setState(() => _liked = !_liked), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Icon( + _liked ? Icons.favorite : Icons.favorite_border, + key: ValueKey(_liked), + color: _liked + ? LibreBeatsTheme.accent + : LibreBeatsTheme.textSecondary, + size: 26, + ), + ), + ), + ], + ), + ), + + // Progress + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 4), + child: Column( + children: [ + SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), + activeTrackColor: LibreBeatsTheme.accent, + inactiveTrackColor: LibreBeatsTheme.border, + thumbColor: Colors.white, + overlayColor: LibreBeatsTheme.accentDim, + ), + child: Slider( + value: player.progress.clamp(0.0, 1.0), + onChanged: player.seek, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_formatDuration(player.position), + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12)), + Text(_formatDuration(player.duration), + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12)), + ], + ), + ], + ), + ), + + // Controls + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Shuffle + IconButton( + icon: Icon( + Icons.shuffle_rounded, + color: player.shuffle == ShuffleMode.on + ? LibreBeatsTheme.accent + : LibreBeatsTheme.textSecondary, + ), + onPressed: player.toggleShuffle, + ), + // Prev + IconButton( + icon: const Icon(Icons.skip_previous_rounded, + size: 36, color: LibreBeatsTheme.textPrimary), + onPressed: player.skipPrev, + ), + // Play/Pause + GestureDetector( + onTap: player.togglePlayPause, + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: LibreBeatsTheme.textPrimary, + shape: BoxShape.circle, + ), + child: Icon( + player.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: LibreBeatsTheme.background, + size: 32, + ), + ), + ), + // Next + IconButton( + icon: const Icon(Icons.skip_next_rounded, + size: 36, color: LibreBeatsTheme.textPrimary), + onPressed: player.skipNext, + ), + // Repeat + IconButton( + icon: Icon( + player.repeat == RepeatMode.one + ? Icons.repeat_one_rounded + : Icons.repeat_rounded, + color: player.repeat != RepeatMode.none + ? LibreBeatsTheme.accent + : LibreBeatsTheme.textSecondary, + ), + onPressed: player.toggleRepeat, + ), + ], + ), + ), + + // Volume + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Row( + children: [ + const Icon(Icons.volume_mute_rounded, + color: LibreBeatsTheme.textSecondary, size: 18), + Expanded( + child: SliderTheme( + data: SliderThemeData( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + activeTrackColor: LibreBeatsTheme.textSecondary, + inactiveTrackColor: LibreBeatsTheme.border, + thumbColor: LibreBeatsTheme.textSecondary, + overlayColor: LibreBeatsTheme.accentDim, + ), + child: Slider( + value: player.volume, + onChanged: player.setVolume, + ), + ), + ), + const Icon(Icons.volume_up_rounded, + color: LibreBeatsTheme.textSecondary, size: 18), + ], + ), + ), + ], + ), + ); + }, + ); + } + + String _formatDuration(Duration d) { + final m = d.inMinutes; + final s = d.inSeconds % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } +} \ No newline at end of file diff --git a/src/frontend/lib/widgets/shared_widgets.dart b/src/frontend/lib/widgets/shared_widgets.dart new file mode 100644 index 0000000..d24d965 --- /dev/null +++ b/src/frontend/lib/widgets/shared_widgets.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import '../theme/app_theme.dart'; + +// ── Album Art Placeholder ────────────────────────────────────────────────── +class AlbumArt extends StatelessWidget { + final String? url; + final double size; + final double radius; + final String? fallbackEmoji; + + const AlbumArt({ + super.key, + this.url, + this.size = 52, + this.radius = 8, + this.fallbackEmoji, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(radius), + gradient: const LinearGradient( + colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + fallbackEmoji ?? '🎵', + style: TextStyle(fontSize: size * 0.45), + ), + ), + ); + } +} + +// ── Song Tile ────────────────────────────────────────────────────────────── +class SongTile extends StatelessWidget { + final Song song; + final VoidCallback? onTap; + final VoidCallback? onMore; + final bool showDuration; + + const SongTile({ + super.key, + required this.song, + this.onTap, + this.onMore, + this.showDuration = true, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + AlbumArt(size: 48, fallbackEmoji: '🎵'), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(song.title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text('${song.artist} • ${song.album}', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + if (showDuration) + Text(song.durationString, + style: const TextStyle( + color: LibreBeatsTheme.textDim, fontSize: 12)), + const SizedBox(width: 4), + if (onMore != null) + IconButton( + icon: const Icon(Icons.more_vert, size: 18), + color: LibreBeatsTheme.textSecondary, + onPressed: onMore, + splashRadius: 20, + ), + ], + ), + ), + ); + } +} + +// ── Playlist Card (vertical) ─────────────────────────────────────────────── +class PlaylistCard extends StatelessWidget { + final Playlist playlist; + final VoidCallback? onTap; + final double width; + + const PlaylistCard({ + super.key, + required this.playlist, + this.onTap, + this.width = 140, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + width: width, + height: width, + decoration: BoxDecoration( + color: LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(10), + gradient: LinearGradient( + colors: playlist.isServer + ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)] + : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + playlist.isServer ? '📡' : '🎵', + style: TextStyle(fontSize: width * 0.35), + ), + ), + ), + if (playlist.isServer) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF1DB95422), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFF1DB954), width: 1), + ), + child: const Text('SERVER', + style: TextStyle( + color: Color(0xFF1DB954), + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.5)), + ), + ), + ], + ), + const SizedBox(height: 8), + Text(playlist.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text('${playlist.songCount} songs', + style: const TextStyle( + color: LibreBeatsTheme.textSecondary, fontSize: 11)), + ], + ), + ), + ); + } +} + +// ── Section Header ───────────────────────────────────────────────────────── +class SectionHeader extends StatelessWidget { + final String title; + final String? actionLabel; + final VoidCallback? onAction; + + const SectionHeader({ + super.key, + required this.title, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3)), + if (actionLabel != null) + GestureDetector( + onTap: onAction, + child: Text(actionLabel!, + style: const TextStyle( + color: LibreBeatsTheme.accent, + fontSize: 13, + fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +// ── Accent Button ────────────────────────────────────────────────────────── +class AccentButton extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onTap; + final bool outlined; + + const AccentButton({ + super.key, + required this.label, + this.icon, + this.onTap, + this.outlined = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + decoration: BoxDecoration( + color: outlined ? Colors.transparent : LibreBeatsTheme.accent, + borderRadius: BorderRadius.circular(24), + border: outlined + ? Border.all(color: LibreBeatsTheme.accent, width: 1.5) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, + color: outlined ? LibreBeatsTheme.accent : Colors.white, + size: 16), + const SizedBox(width: 6), + ], + Text(label, + style: TextStyle( + color: outlined ? LibreBeatsTheme.accent : Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700)), + ], + ), + ), + ); + } +} + +// ── Chip Tag ─────────────────────────────────────────────────────────────── +class ChipTag extends StatelessWidget { + final String label; + final Color? color; + + const ChipTag({super.key, required this.label, this.color}); + + @override + Widget build(BuildContext context) { + final c = color ?? LibreBeatsTheme.accent; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: c.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: c.withOpacity(0.5), width: 1), + ), + child: Text(label, + style: TextStyle( + color: c, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5)), + ); + } +} + +// ── Shimmer Loader ───────────────────────────────────────────────────────── +class ShimmerBox extends StatefulWidget { + final double width; + final double height; + final double radius; + + const ShimmerBox({ + super.key, + required this.width, + required this.height, + this.radius = 8, + }); + + @override + State createState() => _ShimmerBoxState(); +} + +class _ShimmerBoxState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1200)) + ..repeat(reverse: true); + _anim = Tween(begin: 0.3, end: 0.7).animate(_ctrl); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Color.lerp( + LibreBeatsTheme.surface, LibreBeatsTheme.surfaceVariant, _anim.value), + borderRadius: BorderRadius.circular(widget.radius), + ), + ), + ); + } +} \ No newline at end of file diff --git a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..864c093 100644 --- a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,14 @@ import FlutterMacOS import Foundation +import audio_session +import just_audio +import shared_preferences_foundation +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/src/frontend/pubspec.lock b/src/frontend/pubspec.lock index 688d871..4fce8bb 100644 --- a/src/frontend/pubspec.lock +++ b/src/frontend/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" boolean_selector: dependency: transitive description: @@ -17,6 +25,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -33,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,11 +105,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -75,6 +155,75 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -107,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +288,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -139,6 +328,150 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -152,6 +485,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -176,6 +549,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -192,6 +573,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -208,6 +605,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.7 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/src/frontend/pubspec.yaml b/src/frontend/pubspec.yaml index b54c581..7a11e65 100644 --- a/src/frontend/pubspec.yaml +++ b/src/frontend/pubspec.yaml @@ -30,6 +30,16 @@ environment: dependencies: flutter: sdk: flutter + provider: ^6.1.1 + shared_preferences: ^2.2.2 + http: ^1.2.0 + just_audio: ^0.9.36 + audio_session: ^0.1.18 + cached_network_image: ^3.3.1 + uuid: ^4.3.3 + path_provider: ^2.1.2 + sqflite: ^2.3.2 + intl: ^0.19.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. From 27e8120504075eff1a98f82d82935e7ec5885248 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 01:02:44 +0100 Subject: [PATCH 19/77] :))))))))))))))))))))))) --- src/frontend/lib/screens/home_screen.dart | 54 +++++++++++------------ src/frontend/lib/theme/app_theme.dart | 37 +++++++--------- 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/src/frontend/lib/screens/home_screen.dart b/src/frontend/lib/screens/home_screen.dart index 41fa312..9ba58ba 100644 --- a/src/frontend/lib/screens/home_screen.dart +++ b/src/frontend/lib/screens/home_screen.dart @@ -42,33 +42,28 @@ class HomeScreen extends StatelessWidget { ), actions: [ IconButton( - icon: const Icon(Icons.notifications_none_rounded, color: LibreBeatsTheme.textSecondary), + icon: const Icon(Icons.notifications_off_rounded, color: LibreBeatsTheme.textSecondary), onPressed: () {}, ), - const CircleAvatar( - radius: 16, - backgroundColor: LibreBeatsTheme.accent, - child: Text('U', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700)), - ), const SizedBox(width: 8), ], ), // Quick Filter Chips - SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Row( - children: [ - _FilterChip(label: 'All', selected: true), - _FilterChip(label: 'Music'), - _FilterChip(label: 'Podcasts'), - _FilterChip(label: 'Mixes'), - ], - ), - ), - ), + // SliverToBoxAdapter( + // child: SingleChildScrollView( + // scrollDirection: Axis.horizontal, + // padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + // child: Row( + // children: [ + // _FilterChip(label: 'All', selected: true), + // _FilterChip(label: 'Music'), + // _FilterChip(label: 'Podcasts'), + // _FilterChip(label: 'Mixes'), + // ], + // ), + // ), + // ), // Last Played Banner if (library.lastPlayedSong != null) @@ -84,15 +79,16 @@ class HomeScreen extends StatelessWidget { ), // Recently Played Playlists (grid) - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader(title: 'Recently Played'), - _RecentGrid(library: library, player: player), - ], - ), - ), + if (library.playlistsByLastPlayed.isNotEmpty) + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Recently Played'), + _RecentGrid(library: library, player: player), + ], + ), + ), // Your Playlists SliverToBoxAdapter( diff --git a/src/frontend/lib/theme/app_theme.dart b/src/frontend/lib/theme/app_theme.dart index dda7807..0885e36 100644 --- a/src/frontend/lib/theme/app_theme.dart +++ b/src/frontend/lib/theme/app_theme.dart @@ -1,29 +1,26 @@ -// TODO Implement this library.import 'package:flutter/material.dart'; - - import 'package:flutter/material.dart'; class LibreBeatsTheme { - // Core palette - static const Color background = Color(0xFF0F0F0F); - static const Color surface = Color(0xFF1A1A1A); - static const Color surfaceVariant = Color(0xFF242424); - static const Color card = Color(0xFF181818); - static const Color border = Color(0xFF2A2A2A); +// Core palette +static const Color background = Color(0xFF0A0F0A); +static const Color surface = Color(0xFF111611); +static const Color surfaceVariant = Color(0xFF181F18); +static const Color card = Color(0xFF0F140F); +static const Color border = Color(0xFF263026); - // Accent - static const Color accent = Color(0xFFFF4D4D); - static const Color accentDim = Color(0x22FF4D4D); - static const Color accentGlow = Color(0x55FF4D4D); +// Accent (deep emerald) +static const Color accent = Color(0xFF1DB954); +static const Color accentDim = Color(0x221DB954); +static const Color accentGlow = Color(0x441DB954); - // Text - static const Color textPrimary = Color(0xFFE8E8E8); - static const Color textSecondary = Color(0xFF888888); - static const Color textDim = Color(0xFF555555); +// Text +static const Color textPrimary = Color(0xFFE6EEE6); +static const Color textSecondary = Color(0xFF8C968C); +static const Color textDim = Color(0xFF566156); - // Status - static const Color online = Color(0xFF1DB954); - static const Color offline = Color(0xFF888888); +// Status +static const Color online = Color(0xFF1DB954); +static const Color offline = Color(0xFF7A847A); static ThemeData get theme => ThemeData( brightness: Brightness.dark, From 141bb362bc5935fc106805f405938045ecd4d748 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:17:38 +0100 Subject: [PATCH 20/77] Update AndroidManifest.xml --- .../android/app/src/main/AndroidManifest.xml | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/src/frontend/android/app/src/main/AndroidManifest.xml b/src/frontend/android/app/src/main/AndroidManifest.xml index 410865c..5a86b6a 100644 --- a/src/frontend/android/app/src/main/AndroidManifest.xml +++ b/src/frontend/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,18 @@ - + + + + + + + + - + + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme"/> + + - + + + + + + + + + + + + + + + + + + + + + - + - + + \ No newline at end of file From 87173d262ca62caea6fc9f3e13baaa4a6b915435 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:17:41 +0100 Subject: [PATCH 21/77] Update models.dart --- src/frontend/lib/data/models.dart | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/frontend/lib/data/models.dart b/src/frontend/lib/data/models.dart index 2bad84c..bad5be2 100644 --- a/src/frontend/lib/data/models.dart +++ b/src/frontend/lib/data/models.dart @@ -106,7 +106,7 @@ class Playlist { } // ─── Server ──────────────────────────────────────────────────────────────── -enum ServerType { subsonic, navidrome, jellyfin, emby } +enum ServerType { librebeats } enum ServerStatus { unknown, online, offline, error } @@ -127,7 +127,7 @@ class MusicServer { required this.url, this.username, this.password, - this.type = ServerType.navidrome, + this.type = ServerType.librebeats, this.status = ServerStatus.unknown, this.songCount, this.lastSynced, @@ -135,14 +135,8 @@ class MusicServer { String get typeLabel { switch (type) { - case ServerType.navidrome: - return 'Navidrome'; - case ServerType.subsonic: - return 'Subsonic'; - case ServerType.jellyfin: - return 'Jellyfin'; - case ServerType.emby: - return 'Emby'; + case ServerType.librebeats: + return 'LibreBeats'; } } @@ -153,7 +147,7 @@ class MusicServer { username: json['username'], type: ServerType.values.firstWhere( (t) => t.name == json['type'], - orElse: () => ServerType.navidrome, + orElse: () => ServerType.librebeats, ), ); From 1e566bcba0e83d59799197ff9b38c8788fc99151 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:17:45 +0100 Subject: [PATCH 22/77] Update main.dart --- src/frontend/lib/main.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/frontend/lib/main.dart b/src/frontend/lib/main.dart index 37a7b56..05ab67e 100644 --- a/src/frontend/lib/main.dart +++ b/src/frontend/lib/main.dart @@ -1,16 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:just_audio_background/just_audio_background.dart'; import 'package:provider/provider.dart'; import 'providers/player_provider.dart'; import 'providers/library_provider.dart'; import 'screens/home_screen.dart'; import 'screens/search_screen.dart'; -import 'screens/playlists_screen.dart'; +import 'screens/library_screen.dart'; import 'screens/settings_screen.dart'; import 'widgets/player_widgets.dart'; import 'theme/app_theme.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, @@ -22,6 +23,11 @@ void main() { systemNavigationBarColor: Color(0xFF121212), systemNavigationBarIconBrightness: Brightness.light, )); + await JustAudioBackground.init( + androidNotificationChannelId: 'librebeats.audio', + androidNotificationChannelName: 'Audio playback', + androidNotificationOngoing: true, + ); runApp(const LibreBeatsApp()); } @@ -58,7 +64,7 @@ class _MainShellState extends State { final _screens = const [ HomeScreen(), SearchScreen(), - PlaylistsScreen(), + LibraryScreen(), SettingsScreen(), ]; @@ -75,11 +81,11 @@ class _MainShellState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ + // Mini player sits above nav bar // Mini player sits above nav bar if (player.miniPlayerVisible) - GestureDetector( + MiniPlayer( onTap: () => FullPlayerSheet.show(context), - child: const MiniPlayer(), ), // Navigation bar From e15cfa43df2f1b0b266d66ef1c294601ffcf1532 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:18:36 +0100 Subject: [PATCH 23/77] Update library_provider.dart --- src/frontend/lib/screens/home_screen.dart | 42 ++++++++++++----------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/frontend/lib/screens/home_screen.dart b/src/frontend/lib/screens/home_screen.dart index 9ba58ba..4ec372b 100644 --- a/src/frontend/lib/screens/home_screen.dart +++ b/src/frontend/lib/screens/home_screen.dart @@ -42,8 +42,10 @@ class HomeScreen extends StatelessWidget { ), actions: [ IconButton( - icon: const Icon(Icons.notifications_off_rounded, color: LibreBeatsTheme.textSecondary), - onPressed: () {}, + icon: const Icon(Icons.refresh, color: LibreBeatsTheme.textSecondary), + onPressed: () async { + await library.loadMyMusicData(); + }, ), const SizedBox(width: 8), ], @@ -141,24 +143,24 @@ class HomeScreen extends StatelessWidget { ), // Suggestions (from server mix) - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader( - title: 'Suggested', - actionLabel: 'Refresh', - ), - _SuggestionNote(), - ...library.suggestions.take(4).map((song) => SongTile( - song: song, - onTap: () => player.playSong(song), - onMore: () => _showSongMenu(context, song, library), - )), - const SizedBox(height: 100), - ], - ), - ), + // SliverToBoxAdapter( + // child: Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // const SectionHeader( + // title: 'Suggested', + // actionLabel: 'Refresh', + // ), + // _SuggestionNote(), + // ...library.suggestions.take(1).map((song) => SongTile( + // song: song, + // onTap: () => player.playSong(song), + // onMore: () => _showSongMenu(context, song, library), + // )), + // const SizedBox(height: 100), + // ], + // ), + // ), ], ); }, From ae1e779f2f7e679de18e3a1fd695ed8e2801fa60 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:01 +0100 Subject: [PATCH 24/77] Create library_screen.dart --- src/frontend/lib/screens/library_screen.dart | 327 +++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/frontend/lib/screens/library_screen.dart diff --git a/src/frontend/lib/screens/library_screen.dart b/src/frontend/lib/screens/library_screen.dart new file mode 100644 index 0000000..b6d1678 --- /dev/null +++ b/src/frontend/lib/screens/library_screen.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:librebeats/data/models.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../widgets/shared_widgets.dart'; +import '../theme/app_theme.dart'; +import 'playlist_detail_screen.dart'; + +class LibraryScreen extends StatelessWidget { + const LibraryScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, library, _) { + final playlists = library.playlistsByLastPlayed; + return CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + backgroundColor: LibreBeatsTheme.background, + title: const Text('Library'), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: LibreBeatsTheme.textSecondary), + onPressed: () => _showCreateDialog(context, library), + ), + IconButton( + icon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary), + onPressed: () {}, + ), + ], + ), + + // Sort tabs + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + _SortChip(label: 'Recently played', selected: true), + _SortChip(label: 'A–Z'), + _SortChip(label: 'My playlists'), + _SortChip(label: 'Server'), + ], + ), + ), + ), + + // Playlist list + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final pl = playlists[i]; + return _PlaylistTile( + playlist: pl, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlaylistDetailScreen(playlist: pl), + ), + ), + onDelete: () => _confirmDelete(context, library, pl), + onEdit: () => _showEditDialog(context, library, pl), + ); + }, + childCount: playlists.length, + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ); + }, + ); + } + + void _showCreateDialog(BuildContext context, LibraryProvider library) { + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('New Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Playlist name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + if (ctrl.text.isNotEmpty) { + library.addPlaylist(ctrl.text); + Navigator.pop(context); + } + }, + child: const Text('Create', style: TextStyle(color: LibreBeatsTheme.accent)), + ), + ], + ), + ); + } + + void _showEditDialog(BuildContext context, LibraryProvider library, Playlist pl) { + final ctrl = TextEditingController(text: pl.name); + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Edit Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: LibreBeatsTheme.textPrimary), + decoration: const InputDecoration(hintText: 'Playlist name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + library.updatePlaylist(pl.id, name: ctrl.text); + Navigator.pop(context); + }, + child: const Text('Save', style: TextStyle(color: LibreBeatsTheme.accent)), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context, LibraryProvider library, Playlist pl) { + if (pl.isServer) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete server playlists'), + backgroundColor: LibreBeatsTheme.surface, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: LibreBeatsTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Delete Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + content: Text('Remove "${pl.name}"? This cannot be undone.', + style: const TextStyle(color: LibreBeatsTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), + ), + TextButton( + onPressed: () { + library.removePlaylist(pl.id); + Navigator.pop(context); + }, + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} + +class _PlaylistTile extends StatelessWidget { + final Playlist playlist; + final VoidCallback onTap; + final VoidCallback onDelete; + final VoidCallback onEdit; + + const _PlaylistTile({ + required this.playlist, + required this.onTap, + required this.onDelete, + required this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Cover + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + colors: playlist.isServer + ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)] + : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text(playlist.isServer ? '📡' : '🎵', + style: const TextStyle(fontSize: 26)), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(playlist.name, + style: const TextStyle( + color: LibreBeatsTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + if (playlist.isServer) + ChipTag(label: 'SERVER', color: const Color(0xFF1DB954)), + ], + ), + const SizedBox(height: 3), + Text( + '${playlist.songCount} songs${playlist.lastPlayed != null ? " • " + _timeAgo(playlist.lastPlayed!) : ""}', + style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, size: 18), + color: LibreBeatsTheme.textSecondary, + splashRadius: 20, + onPressed: () => _showMenu(context), + ), + ], + ), + ), + ); + } + + void _showMenu(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: LibreBeatsTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!playlist.isServer) ...[ + ListTile( + leading: const Icon(Icons.edit, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Edit', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () { Navigator.pop(context); onEdit(); }, + ), + ListTile( + leading: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + title: const Text('Delete', style: TextStyle(color: Colors.red)), + onTap: () { Navigator.pop(context); onDelete(); }, + ), + ], + ListTile( + leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20), + title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + String _timeAgo(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } +} + +class _SortChip extends StatelessWidget { + final String label; + final bool selected; + const _SortChip({required this.label, this.selected = false}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary, + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ); + } +} \ No newline at end of file From f5e9446acc7b0a4a556b88ecb862f5b44a4b6955 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:06 +0100 Subject: [PATCH 25/77] Delete playlists_screen.dart --- .../lib/screens/playlists_screen.dart | 327 ------------------ 1 file changed, 327 deletions(-) delete mode 100644 src/frontend/lib/screens/playlists_screen.dart diff --git a/src/frontend/lib/screens/playlists_screen.dart b/src/frontend/lib/screens/playlists_screen.dart deleted file mode 100644 index 07e9ece..0000000 --- a/src/frontend/lib/screens/playlists_screen.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:librebeats/data/models.dart'; -import 'package:provider/provider.dart'; -import '../providers/library_provider.dart'; -import '../widgets/shared_widgets.dart'; -import '../theme/app_theme.dart'; -import 'playlist_detail_screen.dart'; - -class PlaylistsScreen extends StatelessWidget { - const PlaylistsScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, library, _) { - final playlists = library.playlistsByLastPlayed; - return CustomScrollView( - slivers: [ - SliverAppBar( - floating: true, - pinned: true, - backgroundColor: LibreBeatsTheme.background, - title: const Text('Library'), - actions: [ - IconButton( - icon: const Icon(Icons.add, color: LibreBeatsTheme.textSecondary), - onPressed: () => _showCreateDialog(context, library), - ), - IconButton( - icon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary), - onPressed: () {}, - ), - ], - ), - - // Sort tabs - SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Row( - children: [ - _SortChip(label: 'Recently played', selected: true), - _SortChip(label: 'A–Z'), - _SortChip(label: 'My playlists'), - _SortChip(label: 'Server'), - ], - ), - ), - ), - - // Playlist list - SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) { - final pl = playlists[i]; - return _PlaylistTile( - playlist: pl, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PlaylistDetailScreen(playlist: pl), - ), - ), - onDelete: () => _confirmDelete(context, library, pl), - onEdit: () => _showEditDialog(context, library, pl), - ); - }, - childCount: playlists.length, - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - ], - ); - }, - ); - } - - void _showCreateDialog(BuildContext context, LibraryProvider library) { - final ctrl = TextEditingController(); - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: LibreBeatsTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('New Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), - content: TextField( - controller: ctrl, - autofocus: true, - style: const TextStyle(color: LibreBeatsTheme.textPrimary), - decoration: const InputDecoration(hintText: 'Playlist name'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), - ), - TextButton( - onPressed: () { - if (ctrl.text.isNotEmpty) { - library.addPlaylist(ctrl.text); - Navigator.pop(context); - } - }, - child: const Text('Create', style: TextStyle(color: LibreBeatsTheme.accent)), - ), - ], - ), - ); - } - - void _showEditDialog(BuildContext context, LibraryProvider library, Playlist pl) { - final ctrl = TextEditingController(text: pl.name); - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: LibreBeatsTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Edit Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), - content: TextField( - controller: ctrl, - autofocus: true, - style: const TextStyle(color: LibreBeatsTheme.textPrimary), - decoration: const InputDecoration(hintText: 'Playlist name'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), - ), - TextButton( - onPressed: () { - library.updatePlaylist(pl.id, name: ctrl.text); - Navigator.pop(context); - }, - child: const Text('Save', style: TextStyle(color: LibreBeatsTheme.accent)), - ), - ], - ), - ); - } - - void _confirmDelete(BuildContext context, LibraryProvider library, Playlist pl) { - if (pl.isServer) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cannot delete server playlists'), - backgroundColor: LibreBeatsTheme.surface, - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: LibreBeatsTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Delete Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)), - content: Text('Remove "${pl.name}"? This cannot be undone.', - style: const TextStyle(color: LibreBeatsTheme.textSecondary)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)), - ), - TextButton( - onPressed: () { - library.removePlaylist(pl.id); - Navigator.pop(context); - }, - child: const Text('Delete', style: TextStyle(color: Colors.red)), - ), - ], - ), - ); - } -} - -class _PlaylistTile extends StatelessWidget { - final Playlist playlist; - final VoidCallback onTap; - final VoidCallback onDelete; - final VoidCallback onEdit; - - const _PlaylistTile({ - required this.playlist, - required this.onTap, - required this.onDelete, - required this.onEdit, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - // Cover - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - gradient: LinearGradient( - colors: playlist.isServer - ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)] - : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Center( - child: Text(playlist.isServer ? '📡' : '🎵', - style: const TextStyle(fontSize: 26)), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(playlist.name, - style: const TextStyle( - color: LibreBeatsTheme.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ), - if (playlist.isServer) - ChipTag(label: 'SERVER', color: const Color(0xFF1DB954)), - ], - ), - const SizedBox(height: 3), - Text( - '${playlist.songCount} songs${playlist.lastPlayed != null ? " • " + _timeAgo(playlist.lastPlayed!) : ""}', - style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.more_vert, size: 18), - color: LibreBeatsTheme.textSecondary, - splashRadius: 20, - onPressed: () => _showMenu(context), - ), - ], - ), - ), - ); - } - - void _showMenu(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: LibreBeatsTheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), - builder: (_) => Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!playlist.isServer) ...[ - ListTile( - leading: const Icon(Icons.edit, color: LibreBeatsTheme.textSecondary, size: 20), - title: const Text('Edit', style: TextStyle(color: LibreBeatsTheme.textPrimary)), - onTap: () { Navigator.pop(context); onEdit(); }, - ), - ListTile( - leading: const Icon(Icons.delete_outline, color: Colors.red, size: 20), - title: const Text('Delete', style: TextStyle(color: Colors.red)), - onTap: () { Navigator.pop(context); onDelete(); }, - ), - ], - ListTile( - leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20), - title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)), - onTap: () => Navigator.pop(context), - ), - ], - ), - ), - ); - } - - String _timeAgo(DateTime dt) { - final diff = DateTime.now().difference(dt); - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } -} - -class _SortChip extends StatelessWidget { - final String label; - final bool selected; - const _SortChip({required this.label, this.selected = false}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - style: TextStyle( - color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary, - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), - ); - } -} \ No newline at end of file From b9eafd708d8e23b154c7ad217a927d51183d3c5f Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:11 +0100 Subject: [PATCH 26/77] Update settings_screen.dart --- src/frontend/lib/screens/settings_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/lib/screens/settings_screen.dart b/src/frontend/lib/screens/settings_screen.dart index 530dcbe..38d965f 100644 --- a/src/frontend/lib/screens/settings_screen.dart +++ b/src/frontend/lib/screens/settings_screen.dart @@ -406,7 +406,7 @@ class _AddServerFormState extends State<_AddServerForm> { final _url = TextEditingController(); final _user = TextEditingController(); final _pass = TextEditingController(); - ServerType _type = ServerType.navidrome; + ServerType _type = ServerType.librebeats; bool _obscure = true; bool _saving = false; From 82ea2874ed256472c996ef3af7160f73f200543b Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:29 +0100 Subject: [PATCH 27/77] Update GeneratedPluginRegistrant.swift --- src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index 864c093..f774dc0 100644 --- a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import audio_service import audio_session import just_audio import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) From 5a9ae129a90f75a1e67b533f9c7146ad069bdb31 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:32 +0100 Subject: [PATCH 28/77] Update pubspec.lock --- src/frontend/pubspec.lock | 42 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/frontend/pubspec.lock b/src/frontend/pubspec.lock index 4fce8bb..6b58def 100644 --- a/src/frontend/pubspec.lock +++ b/src/frontend/pubspec.lock @@ -9,8 +9,32 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_service: + dependency: transitive + description: + name: audio_service + sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 + url: "https://pub.dev" + source: hosted + version: "0.18.18" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" audio_session: - dependency: "direct main" + dependency: transitive description: name: audio_session sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" @@ -200,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" just_audio: dependency: "direct main" description: @@ -208,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.46" + just_audio_background: + dependency: "direct main" + description: + name: just_audio_background + sha256: "3900825701164577db65337792bc122b66f9eb1245ee47e7ae8244ae5ceb8030" + url: "https://pub.dev" + source: hosted + version: "0.0.1-beta.17" just_audio_platform_interface: dependency: transitive description: From 048e88061dbca85cd2800b015c7f49683d70aa66 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 20:19:35 +0100 Subject: [PATCH 29/77] Update pubspec.yaml --- src/frontend/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/pubspec.yaml b/src/frontend/pubspec.yaml index 7a11e65..3206678 100644 --- a/src/frontend/pubspec.yaml +++ b/src/frontend/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: shared_preferences: ^2.2.2 http: ^1.2.0 just_audio: ^0.9.36 - audio_session: ^0.1.18 + just_audio_background: any cached_network_image: ^3.3.1 uuid: ^4.3.3 path_provider: ^2.1.2 From a888dad7dc0c8ca4e040c9356c067b5daf21692b Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 8 Mar 2026 23:01:39 +0100 Subject: [PATCH 30/77] Update AndroidManifest.xml --- src/frontend/android/app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/frontend/android/app/src/main/AndroidManifest.xml b/src/frontend/android/app/src/main/AndroidManifest.xml index 5a86b6a..9fe60b2 100644 --- a/src/frontend/android/app/src/main/AndroidManifest.xml +++ b/src/frontend/android/app/src/main/AndroidManifest.xml @@ -42,10 +42,11 @@ + From 0066ce14e3334dd42fb1681f3816df72a8fa4282 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:16:34 +0100 Subject: [PATCH 31/77] Update build.sh --- src/backend/build.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backend/build.sh b/src/backend/build.sh index 6451934..09a8476 100644 --- a/src/backend/build.sh +++ b/src/backend/build.sh @@ -12,6 +12,8 @@ else echo "Project directory already exists, skipping mkdir." fi +sudo rm -rf "$BUILD_DIRECTORY" + # Create local backend folder if missing # Tree should look like this # ├── librebeats_api/build (output directory) @@ -37,9 +39,6 @@ fi # # # Switch to your project directory cd "$BUILD_DIRECTORY" -# # # Pull the latest images -docker compose pull - # # # To generate and apply all secrets at once you can run: https://supabase.com/docs/guides/self-hosting/docker#quick-setup-experimental if [ "$GENERATE_KEYS" = true ]; then echo "Generating new keys." From 069a45791677a09229d6c709916adff29d864954 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:16:40 +0100 Subject: [PATCH 32/77] Update copy.sh --- src/backend/copy.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backend/copy.sh b/src/backend/copy.sh index d99af3f..9624ca3 100644 --- a/src/backend/copy.sh +++ b/src/backend/copy.sh @@ -8,5 +8,7 @@ cp -rf $_WORKING_DIR/supabase/* "$BUILD_DIRECTORY" # # # Switch to your project directory cd "$BUILD_DIRECTORY/service/migration" || exit 1 -docker compose build migrations -docker compose up migrations -d --force-recreate \ No newline at end of file +docker compose build audio +docker compose up audio -d + +# --force-recreate \ No newline at end of file From 3828cc2aa8384da9c2c390eb049e1f995cf970ef Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:16:55 +0100 Subject: [PATCH 33/77] Update docker-compose.yml --- src/backend/supabase/docker-compose.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/docker-compose.yml b/src/backend/supabase/docker-compose.yml index d46ffcd..a5771c5 100644 --- a/src/backend/supabase/docker-compose.yml +++ b/src/backend/supabase/docker-compose.yml @@ -401,7 +401,8 @@ services: condition: service_healthy environment: POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres - + + # Ytdlp wrapper, Upload service, and logger for librebeats audio processing audio: container_name: supabase-audio build: @@ -416,6 +417,8 @@ services: environment: POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres QUEUE_NAME: audiopipe-input + STORAGE_URL: http://kong:8000/storage/v1 + STORAGE_KEY: ${SERVICE_ROLE_KEY} # Comment out everything below this point if you are using an external Postgres database db: From 055ffa498fe066490a73800d288d82dcf37c4025 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:01 +0100 Subject: [PATCH 34/77] Update Dockerfile --- src/backend/supabase/service/audio/Dockerfile | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile index ec3efb4..f547906 100644 --- a/src/backend/supabase/service/audio/Dockerfile +++ b/src/backend/supabase/service/audio/Dockerfile @@ -1,34 +1,41 @@ -FROM golang:1.26 AS builder +# Stage 1: Builder +FROM golang:1.26-alpine AS builder WORKDIR /app + +# Copy go mod files first for better layer caching +COPY go.mod go.sum* ./ +RUN go mod download + +# Copy source code and build COPY . . RUN go build -o main +# Stage 2: Final image FROM ubuntu:24.04 WORKDIR /app -COPY --from=builder /app/main . - -COPY cookies.txt /app/cookies.txt - -# install ffmpeg -RUN apt-get update && apt-get install -y ffmpeg - -# install wget -RUN apt-get install -y wget - -# install curl -RUN apt-get install -y curl - -# install unzip -RUN apt-get install -y unzip - -# install deno / need to set path ?? +# Combine apt-get commands to reduce layers and cleanup in same layer +RUN apt-get update && apt-get install -y \ + ffmpeg \ + wget \ + curl \ + unzip \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install yt-dlp +RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp \ + && chmod +x /usr/local/bin/yt-dlp + +# Install deno RUN wget -qO- https://deno.land/install.sh | sh -# install yt-dlp -RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp && chmod +x /usr/local/bin/yt-dlp +# Set path for deno +ENV PATH="/root/.deno/bin:${PATH}" -# Install npm -RUN apt-get install -y npm +# Copy binary and config from builder +COPY --from=builder /app/main . +COPY cookies.txt /app/cookies.txt CMD ["./main"] \ No newline at end of file From bf9786a13918bd6df258cf752982faf017e3bfb3 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:05 +0100 Subject: [PATCH 35/77] Update go.sum --- src/backend/supabase/service/audio/go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/supabase/service/audio/go.sum b/src/backend/supabase/service/audio/go.sum index cc64971..428385f 100644 --- a/src/backend/supabase/service/audio/go.sum +++ b/src/backend/supabase/service/audio/go.sum @@ -119,6 +119,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/supabase-community/postgrest-go v0.0.12 h1:4xJmimJra904t6Rj+umPyu1qm6ih7rhd7fvgqAblajc= github.com/supabase-community/postgrest-go v0.0.12/go.mod h1:cw6LfzMyK42AOSBA1bQ/HZ381trIJyuui2GWhraW7Cc= +github.com/supabase-community/storage-go v0.8.1 h1:EwD0vr+ADBIjBWH8G69AxWuvdFhifv64cfE/sjRky6I= +github.com/supabase-community/storage-go v0.8.1/go.mod h1:oBKcJf5rcUXy3Uj9eS5wR6mvpwbmvkjOtAA+4tGcdvQ= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= From 43db20d9b6e56d15fd60fb6b32a135230d587942 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:12 +0100 Subject: [PATCH 36/77] Update go.mod --- src/backend/supabase/service/audio/go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/supabase/service/audio/go.mod b/src/backend/supabase/service/audio/go.mod index 1d112e5..a0cfc06 100644 --- a/src/backend/supabase/service/audio/go.mod +++ b/src/backend/supabase/service/audio/go.mod @@ -17,6 +17,7 @@ require ( github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle v1.1.3 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/supabase-community/storage-go v0.8.1 // indirect golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect From 947d1294efd74658b408d167e516c0a3c405ff81 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:18 +0100 Subject: [PATCH 37/77] Update log.go --- src/backend/supabase/service/audio/log.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go index 54442d0..9d5bf67 100644 --- a/src/backend/supabase/service/audio/log.go +++ b/src/backend/supabase/service/audio/log.go @@ -21,18 +21,18 @@ func NewYtdlpLogger() *YtdlpLogger { } func (l *YtdlpLogger) CreateNewLog(title string) (*YtdlpOutputLog, error) { - lastinsertedId, err := l.database.InsertWithReturningIdUUID("INSERT INTO YtdlpOutputLog (title, progressState) VALUES ($1, $2) RETURNING id", title, 0) + lastinsertedId, err := l.database.InsertWithReturningIdUUID("INSERT INTO YtdlpOutputLog (Title, ProgressState) VALUES ($1, $2) RETURNING id", title, Created) if err != nil { return nil, err } return &YtdlpOutputLog{ Id: lastinsertedId, - ProgressState: 0, + ProgressState: int(Created), Title: &title, }, nil } func (l *YtdlpLogger) UpdateLog(log *YtdlpOutputLog) error { - _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET title = $1, outputlog = $2, erroroutputlog = $3, progressstate = $4 WHERE id = $5", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.Id) + _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, OutputBase64 = $2, ErrorOutputBase64 = $3, ProgressState = $4 WHERE id = $5", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.Id) return err } From e9a869d8ddf4a00aaa999415337fd629fcaeafb8 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:29 +0100 Subject: [PATCH 38/77] Update main.go --- src/backend/supabase/service/audio/main.go | 116 ++++++++++++++++++--- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index 0ea9d69..8fd8e07 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -1,7 +1,9 @@ package main import ( + "encoding/json" "fmt" + "io/fs" "os" "strings" "time" @@ -18,7 +20,10 @@ func main() { // Create queue listener var queueListener = *createQueueListener() - var logger = NewYtdlpLogger() + // Storage + var storage = NewStorageService() + + //var logger = NewYtdlpLogger() for true { audioQueueMessage, _ := listenForMessage(&queueListener) @@ -29,36 +34,113 @@ func main() { fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message) - log, err := logger.CreateNewLog(string(audioQueueMessage.Message)) + messageBody := map[string]interface{}{} - if err != nil { - fmt.Printf("Created log with id: %s\n", log.Id) - } else { - fmt.Errorf("Failed to create log for message id: %d, error: %s\n", audioQueueMessage.Id, err.Error()) + json.Unmarshal(audioQueueMessage.Message, &messageBody) + + // check if url is a playlist or single video + sourceUrl := string(messageBody["url"].(string)) + isPlaylist := strings.Contains(sourceUrl, "playlist?") + + basePath := "/app/temp" + outputLocation := fmt.Sprintf("%s/%d", basePath, audioQueueMessage.Id) + idsFile := fmt.Sprintf("%s/ids.txt", outputLocation) + namesFile := fmt.Sprintf("%s/names.txt", outputLocation) + durationFile := fmt.Sprintf("%s/duration.txt", outputLocation) + playlistTitleFile := fmt.Sprintf("%s/playlist_title.txt", outputLocation) + playlistIdFile := fmt.Sprintf("%s/playlist_id.txt", outputLocation) + logOutput := fmt.Sprintf("%s/output.log", outputLocation) + logOutputError := fmt.Sprintf("%s/error.log", outputLocation) + + if !pathExists(basePath) { + err := os.Mkdir(basePath, fs.ModePerm|fs.ModeDir) + if err != nil { + fmt.Println(err.Error()) + panic("Failed to create base directory") + } } + if !pathExists(outputLocation) { + err := os.Mkdir(outputLocation, fs.ModePerm|fs.ModeDir) + if err != nil { + fmt.Println(err.Error()) + panic("Failed to create output directory") + } + } + + filesToDelete := []string{ + idsFile, + namesFile, + durationFile, + playlistTitleFile, + playlistIdFile, + logOutput, + logOutputError, + outputLocation, + } // split off between playlist and single download - sourceUrl := string(audioQueueMessage.Message) - isPlaylist := strings.Contains(sourceUrl, "playlist?") + if !isPlaylist { + // Single + download := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, playlistTitleFile, playlistIdFile, sourceUrl, logOutput, logOutputError, "opus") + + // Handle download result + if !download { + fmt.Println("Failed to download single video") + } else { + fmt.Println("Single video downloaded successfully") + + ids, err := readLines(idsFile) + + if err != nil { + fmt.Println("Failed to read IDs file") + } + + audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, ids[0]) + imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, ids[0]) + + // Upload to storage + audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", ids[0])) + + if err != nil { + fmt.Println("Failed to upload audio file") + } + + imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpg", ids[0])) + + if err != nil { + fmt.Println("Failed to upload image file") + } + + fmt.Printf("Audio: %s\n", audioUploadResponse.Key) + fmt.Printf("Image: %s\n", imageUploadResponse.Key) + } - // check if url is a playlist or single video - if isPlaylist { - fmt.Println("Playlist download detected") } else { - fmt.Println("Single video download detected") + // Playlist + // Get playlist Id } // re-think way to handle playlist downloads, // use old method in mvp where it writes the information to a file and then reads it back to update the database // Works ish - download := FlatSingleDownload("archive.txt", "ids.txt", "names.txt", "duration.txt", "playlist_title.txt", "playlist_id.txt", "https://www.youtube.com/watch?v=s-uEFHxZ_nE", "output.log", "error.log", "", "opus") + // download := FlatSingleDownload("archive.txt", "ids.txt", "names.txt", "duration.txt", "playlist_title.txt", "playlist_id.txt", "https://www.youtube.com/watch?v=s-uEFHxZ_nE", "output.log", "error.log", "opus") // Handle download result - if !download { - fmt.Println("Failed to download playlist") - } else { - fmt.Println("Playlist downloaded successfully") + // if !download { + // fmt.Println("Failed to download playlist") + // } else { + // fmt.Println("Playlist downloaded successfully") + // } + + // Clean up files + for _, file := range filesToDelete { + err := os.RemoveAll(file) + if err != nil { + fmt.Printf("Failed to delete file: %s\n", file) + } else { + fmt.Printf("Deleted: %s\n", file) + } } } } From 19e0c230a52d6c6d07cd34c687d9c775114ff08e Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:34 +0100 Subject: [PATCH 39/77] Update models.go --- src/backend/supabase/service/audio/models.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go index a21d2db..ffa981c 100644 --- a/src/backend/supabase/service/audio/models.go +++ b/src/backend/supabase/service/audio/models.go @@ -7,6 +7,16 @@ import ( "github.com/google/uuid" ) +type ProgressState int + +const ( + Created ProgressState = iota + InProgress ProgressState = iota + Completed ProgressState = iota + Downloading ProgressState = iota + Failed ProgressState = iota +) + type AudioPipeQueueMessage struct { Id int64 `json:"msg_id"` Message json.RawMessage `json:"message"` From c38de2a4676212b476dd97879877fde0dc2dfafb Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:41 +0100 Subject: [PATCH 40/77] Update queue.go --- src/backend/supabase/service/audio/queue.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/backend/supabase/service/audio/queue.go b/src/backend/supabase/service/audio/queue.go index 7ad730d..d28927d 100644 --- a/src/backend/supabase/service/audio/queue.go +++ b/src/backend/supabase/service/audio/queue.go @@ -45,11 +45,5 @@ func (ql *QueueListener) Pop() (*AudioPipeQueueMessage, error) { return nil, err } - // var keyValues map[string]interface{} - - // if err := json.Unmarshal([]byte(result.Message), &keyValues); err != nil { - // return AudioPipeQueueMessage{}, err - // } - return &audioQueueMessage, nil } From 4ca4fec633d724606353141316b59f4f1303eae9 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:17:46 +0100 Subject: [PATCH 41/77] Update sourceHelper.go --- .../supabase/service/audio/sourceHelper.go | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go index 4e1fe7a..7ce42fd 100644 --- a/src/backend/supabase/service/audio/sourceHelper.go +++ b/src/backend/supabase/service/audio/sourceHelper.go @@ -9,7 +9,6 @@ import ( var cookiesPath = "/app/cookies.txt" func FlatPlaylistDownload( - archiveFileName string, idsFileName string, namesFileName string, durationFileName string, @@ -22,14 +21,15 @@ func FlatPlaylistDownload( Stdout, err := os.Create(logOutput) if err != nil { - fmt.Println(err) - panic(-65465465) + fmt.Println(err.Error()) + return false } Stderr, err := os.Create(logOutputError) if err != nil { - panic(-65324465) + fmt.Println(err.Error()) + return false } proc, _err := os.StartProcess( @@ -75,7 +75,8 @@ func FlatPlaylistDownload( } func FlatSingleDownload( - archiveFileName string, + //archiveFileName string, + outputLocation string, idsFileName string, namesFileName string, durationFileName string, @@ -84,20 +85,21 @@ func FlatSingleDownload( url string, logOutput string, logOutputError string, - storageFolderName string, fileExtension string, ) bool { - Stdout, err := os.OpenFile(logOutput, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + Stdout, err := os.Create(logOutput) if err != nil { - panic(-65465465) + fmt.Println(err.Error()) + return false } - Stderr, err := os.OpenFile(logOutputError, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + Stderr, err := os.Create(logOutputError) if err != nil { - panic(-65324465) + fmt.Println(err.Error()) + return false } proc, _err := os.StartProcess( @@ -117,13 +119,13 @@ func FlatSingleDownload( "--print-to-file", "%(id)s", idsFileName, "--print-to-file", "%(title)s", namesFileName, "--print-to-file", "%(duration)s", durationFileName, - "--output", "/%(id)s.%(ext)s", + "--output", outputLocation + "/%(id)s.%(ext)s", "--concurrent-fragments=20", "--ignore-errors", - fmt.Sprintf("--download-archive=%s", archiveFileName), + // fmt.Sprintf("--download-archive=%s", archiveFileName), // not needed for now "--extractor-args=youtube:player_js_variant=tv", fmt.Sprintf("--cookies=%s", cookiesPath), - "--js-runtimes=deno:/home/admin/.deno/bin", + "--js-runtimes=deno:/root/.deno/bin", "--remote-components=ejs:npm", url, }, From 908c74f596ce9fd8e2a62b5954428d9b1efb1a05 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:18:05 +0100 Subject: [PATCH 42/77] Create storage.go --- src/backend/supabase/service/audio/storage.go | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/backend/supabase/service/audio/storage.go diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go new file mode 100644 index 0000000..19c352d --- /dev/null +++ b/src/backend/supabase/service/audio/storage.go @@ -0,0 +1,75 @@ +package main + +import ( + "os" + + storage "github.com/supabase-community/storage-go" +) + +type IStorageService interface { + UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) + UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error) +} + +type StorageService struct { + IStorageService + client *storage.Client +} + +func NewStorageService() *StorageService { + // Will throw an error if its missing a method implementation from interface + // will throw a compile time error + var _ IStorageService = (*StorageService)(nil) + + storageUrl := os.Getenv("STORAGE_URL") + + if storageUrl == "" { + panic("STORAGE_URL environment variable is not set") + } + + storageKey := os.Getenv("STORAGE_KEY") + + if storageKey == "" { + panic("STORAGE_KEY environment variable is not set") + } + + storageClient := storage.NewClient(storageUrl, storageKey, nil) + + return &StorageService{ + client: storageClient, + } +} + +func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) { + // Open the file you want to upload + file, err := os.Open(filePath) + if err != nil { + return storage.FileUploadResponse{}, err + } + defer file.Close() + + // Upload the file to the specified bucket and path + response, err := s.client.UploadFile("audio-files", fileName, file) + + if err != nil { + return storage.FileUploadResponse{}, err + } + return response, nil +} + +func (s *StorageService) UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error) { + // Open the file you want to upload + file, err := os.Open(filePath) + if err != nil { + return storage.FileUploadResponse{}, err + } + defer file.Close() + + // Upload the file to the specified bucket and path + response, err := s.client.UploadFile("image-files", fileName, file) + + if err != nil { + return storage.FileUploadResponse{}, err + } + return response, nil +} From 45245e173d4e59e107a634206ce60fa1ce09008d Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:18:13 +0100 Subject: [PATCH 43/77] Create util.go --- src/backend/supabase/service/audio/util.go | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/backend/supabase/service/audio/util.go diff --git a/src/backend/supabase/service/audio/util.go b/src/backend/supabase/service/audio/util.go new file mode 100644 index 0000000..c9de80e --- /dev/null +++ b/src/backend/supabase/service/audio/util.go @@ -0,0 +1,37 @@ +package main + +import ( + "bufio" + "os" +) + +func fileExists(path string) bool { + _, err := os.Stat(path) + // No error, file exists + return err == nil +} + +// readLines reads a whole file into memory +// and returns a slice of its lines. +func readLines(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +func pathExists(path string) bool { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false + } + return err == nil && (info.IsDir()) +} From 5ae710b60f66679325c431a35ab2c4ebcf40a26e Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:18:21 +0100 Subject: [PATCH 44/77] Update migration.go --- .../supabase/service/migration/migration.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/backend/supabase/service/migration/migration.go b/src/backend/supabase/service/migration/migration.go index f44dc82..0645be1 100644 --- a/src/backend/supabase/service/migration/migration.go +++ b/src/backend/supabase/service/migration/migration.go @@ -119,13 +119,19 @@ func (m *Migration) Run() error { } func (m *Migration) _LastAppliedMigrationId() (int, error) { + var lastAppliedMigrationId int = -1 - var lastAppliedMigrationId int - - err := m._connection.QueryRow(context.Background(), "SELECT id FROM librebeats.migrations ORDER BY runon DESC LIMIT 1").Scan(&lastAppliedMigrationId) + err := m._connection.QueryRow(context.Background(), "SELECT Id FROM Librebeats.Migrations ORDER BY runon DESC LIMIT 1").Scan(&lastAppliedMigrationId) if err != nil { - fmt.Println("Error fetching last applied migration id:", err.Error()) + errorMessage := err.Error() + // If the error is because the migrations table does not exist, it means that no migrations have been applied yet, so we can return -1 without an error + if strings.Contains(errorMessage, "ERROR: relation \"librebeats.migrations\" does not exist (SQLSTATE 42P01)") { + fmt.Println("Migrations table does not exist, assuming no migrations have been applied yet.") + return -1, nil + } + + fmt.Println("Error fetching last applied migrationId:", errorMessage) return -1, err } From 5b8f6eacedd5ace3604dcf3909031b7126c505e4 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 9 Mar 2026 23:18:27 +0100 Subject: [PATCH 45/77] Update 0 initial.sql --- .../service/migration/scripts/0 initial.sql | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 281d428..387b6e6 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -1,12 +1,12 @@ -- 1. Schema Setup -CREATE SCHEMA IF NOT EXISTS librebeats; +CREATE SCHEMA IF NOT EXISTS Librebeats; -- 4. Internal Table CREATE TABLE IF NOT EXISTS Librebeats.Migrations( - Id serial PRIMARY KEY, - FileName text NOT NULL, - Content text NOT NULL, - RunOn timestamptz NOT NULL DEFAULT now() + Id SERIAL PRIMARY KEY, + FileName TEXT NOT NULL, + Content TEXT NOT NULL, + RunOn TIMESTAMP NOT NULL DEFAULT now() ); -- Enable RLS: With no policies, it is accessible ONLY by service_role @@ -14,8 +14,8 @@ ALTER TABLE Librebeats.Migrations ENABLE ROW LEVEL SECURITY; -- 5. Final Grants -- GRANT USAGE ON SCHEMA librebeats, pgmq_public, pgmq TO service_role; -GRANT ALL ON ALL TABLES IN SCHEMA librebeats TO service_role; -GRANT ALL ON ALL SEQUENCES IN SCHEMA librebeats TO service_role; +GRANT ALL ON ALL TABLES IN SCHEMA Librebeats TO service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA Librebeats TO service_role; -- Create the audio processing queue SELECT * FROM pgmq.create('audiopipe-input'); @@ -24,7 +24,7 @@ SELECT * FROM pgmq.create('audiopipe-input'); -- SELECT * FROM pgmq.send('audiopipe-input', '{"key": "path/to/audio/file.mp3", "metadata": {"artist": "Artist Name", "album": "Album Name"}}', 0); CREATE TABLE IF NOT EXISTS Librebeats.Audio ( - Id serial PRIMARY KEY, + Id SERIAL PRIMARY KEY, SourceId TEXT NOT NULL, SourceName TEXT NOT NULL, StorageLocation TEXT, @@ -33,15 +33,28 @@ CREATE TABLE IF NOT EXISTS Librebeats.Audio ( CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW() ); +ALTER TABLE Librebeats.Audio ENABLE ROW LEVEL SECURITY; + +-- ONLY authenticated users can access all the Audio table +CREATE POLICY "Authenticated users can access all audio" ON Librebeats.Audio + FOR SELECT + TO authenticated + USING (true); + CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( Id INT PRIMARY KEY, - AudioId serial, Title TEXT NOT NULL, ProgressState INT NOT NULL, - OutputLogBase64 TEXT, - ErrorOutputLogBase64 TEXT, - CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW(), + OutputBase64 TEXT NOT NULL, + ErrorOutputBase64 TEXT, + StartedAtUtc TIMESTAMP NOT NULL DEFAULT NOW(), FinishedAtUtc TIMESTAMP ); +ALTER TABLE Librebeats.YtdlpOutputLog ENABLE ROW LEVEL SECURITY; +-- Only service role can access the YtdlpOutputLog table +CREATE POLICY "Service role can access YtdlpOutputLog" ON Librebeats.YtdlpOutputLog + FOR SELECT + TO authenticated + USING (true); From 8da0b6c31581ff5575980c0c7fb1feb8366df320 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Tue, 10 Mar 2026 21:24:31 +0100 Subject: [PATCH 46/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index 19c352d..c8e4a65 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -49,7 +49,13 @@ func (s *StorageService) UploadAudioFile(filePath string, fileName string) (stor defer file.Close() // Upload the file to the specified bucket and path - response, err := s.client.UploadFile("audio-files", fileName, file) + contentType := "audio/ogg" + upsert := true + options := &storage.FileOptions{ + ContentType: &contentType, + Upsert: &upsert, + } + response, err := s.client.UploadFile("audio-files", fileName, file, *options) if err != nil { return storage.FileUploadResponse{}, err @@ -66,7 +72,13 @@ func (s *StorageService) UploadImageFile(filePath string, fileName string) (stor defer file.Close() // Upload the file to the specified bucket and path - response, err := s.client.UploadFile("image-files", fileName, file) + contentType := "image/jpeg" + upsert := true + options := &storage.FileOptions{ + ContentType: &contentType, + Upsert: &upsert, + } + response, err := s.client.UploadFile("image-files", fileName, file, *options) if err != nil { return storage.FileUploadResponse{}, err From 75019f56b3f38dc7628d48cb855cdfd8d19ead9e Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Tue, 10 Mar 2026 21:24:37 +0100 Subject: [PATCH 47/77] Update sourceHelper.go --- .../supabase/service/audio/sourceHelper.go | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go index 7ce42fd..23baad5 100644 --- a/src/backend/supabase/service/audio/sourceHelper.go +++ b/src/backend/supabase/service/audio/sourceHelper.go @@ -17,19 +17,19 @@ func FlatPlaylistDownload( url string, logOutput string, logOutputError string, -) bool { +) (bool, error) { Stdout, err := os.Create(logOutput) if err != nil { fmt.Println(err.Error()) - return false + return false, err } Stderr, err := os.Create(logOutputError) if err != nil { fmt.Println(err.Error()) - return false + return false, err } proc, _err := os.StartProcess( @@ -68,10 +68,10 @@ func FlatPlaylistDownload( state, err := proc.Wait() if err != nil { - return false + return false, err } - return state.Success() + return state.Success(), nil } func FlatSingleDownload( @@ -86,20 +86,20 @@ func FlatSingleDownload( logOutput string, logOutputError string, fileExtension string, -) bool { +) (bool, error) { Stdout, err := os.Create(logOutput) if err != nil { fmt.Println(err.Error()) - return false + return false, err } Stderr, err := os.Create(logOutputError) if err != nil { fmt.Println(err.Error()) - return false + return false, err } proc, _err := os.StartProcess( @@ -111,7 +111,7 @@ func FlatSingleDownload( "--extract-audio", "--audio-quality=0", fmt.Sprintf("--audio-format=%s", fileExtension), - "--convert-thumbnails=jpg", + "--convert-thumbnails=jpeg", "--force-ipv4", "--downloader=aria2c", "--no-keep-video", @@ -137,15 +137,16 @@ func FlatSingleDownload( }, }, ) + if _err != nil { - log.Fatal(_err) + return false, _err } state, err := proc.Wait() if err != nil { - return false + return false, err } - return state.Success() + return state.Success(), nil } From f6c307619e2385fa64d5116bde03b7abbdc16f69 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Tue, 10 Mar 2026 21:24:49 +0100 Subject: [PATCH 48/77] Update main.go --- src/backend/supabase/service/audio/main.go | 150 ++++++++++++--------- 1 file changed, 89 insertions(+), 61 deletions(-) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index 8fd8e07..a1123c9 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -23,7 +23,7 @@ func main() { // Storage var storage = NewStorageService() - //var logger = NewYtdlpLogger() + var logger = NewYtdlpLogger() for true { audioQueueMessage, _ := listenForMessage(&queueListener) @@ -36,7 +36,17 @@ func main() { messageBody := map[string]interface{}{} - json.Unmarshal(audioQueueMessage.Message, &messageBody) + err := json.Unmarshal(audioQueueMessage.Message, &messageBody) + + if err != nil { + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to unmarshal message body for message id: %d", audioQueueMessage.Id)) + log.OutputLogBase64 = string(audioQueueMessage.Message) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + logger.UpdateLog(log) + fmt.Printf("Failed to unmarshal message body for message id: %d\n Error: %s\n", audioQueueMessage.Id, err.Error()) + continue + } // check if url is a playlist or single video sourceUrl := string(messageBody["url"].(string)) @@ -52,96 +62,103 @@ func main() { logOutput := fmt.Sprintf("%s/output.log", outputLocation) logOutputError := fmt.Sprintf("%s/error.log", outputLocation) + filesToDelete := []string{ + idsFile, + namesFile, + durationFile, + playlistTitleFile, + playlistIdFile, + logOutput, + logOutputError, + outputLocation, + } + if !pathExists(basePath) { err := os.Mkdir(basePath, fs.ModePerm|fs.ModeDir) if err != nil { - fmt.Println(err.Error()) - panic("Failed to create base directory") + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create base directory for message id: %d", audioQueueMessage.Id)) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) + continue } } if !pathExists(outputLocation) { err := os.Mkdir(outputLocation, fs.ModePerm|fs.ModeDir) if err != nil { - fmt.Println(err.Error()) - panic("Failed to create output directory") + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create output directory for message id: %d", audioQueueMessage.Id)) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) + continue } } - filesToDelete := []string{ - idsFile, - namesFile, - durationFile, - playlistTitleFile, - playlistIdFile, - logOutput, - logOutputError, - outputLocation, - } // split off between playlist and single download if !isPlaylist { // Single - download := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, playlistTitleFile, playlistIdFile, sourceUrl, logOutput, logOutputError, "opus") - - // Handle download result - if !download { - fmt.Println("Failed to download single video") - } else { - fmt.Println("Single video downloaded successfully") + _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, playlistTitleFile, playlistIdFile, sourceUrl, logOutput, logOutputError, "opus") - ids, err := readLines(idsFile) + if err != nil { + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to download single video for message id: %d", audioQueueMessage.Id)) + log.OutputLogBase64 = string(audioQueueMessage.Message) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) + fmt.Printf("Failed to download single video for message id: %d\n Error: %s\n", audioQueueMessage.Id, err.Error()) + cleanUopFiles(filesToDelete) + continue + } - if err != nil { - fmt.Println("Failed to read IDs file") - } + ids, err := readLines(idsFile) - audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, ids[0]) - imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, ids[0]) + if err != nil { + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to read IDs file for message id: %d", audioQueueMessage.Id)) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) + cleanUopFiles(filesToDelete) + continue + } - // Upload to storage - audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", ids[0])) + audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, ids[0]) + imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, ids[0]) - if err != nil { - fmt.Println("Failed to upload audio file") - } + // Upload to storage + audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", ids[0])) - imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpg", ids[0])) + if err != nil { + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload audio file %s for message id: %d", audioFilePath, audioQueueMessage.Id)) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) + } - if err != nil { - fmt.Println("Failed to upload image file") - } + imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpg", ids[0])) - fmt.Printf("Audio: %s\n", audioUploadResponse.Key) - fmt.Printf("Image: %s\n", imageUploadResponse.Key) + if err != nil { + log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload image file %s file for message id: %d", imageFilePath, audioQueueMessage.Id)) + log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(log) } + fmt.Printf("Audio storage location: %s\n", audioUploadResponse.Key) + fmt.Printf("Image storage location: %s\n", imageUploadResponse.Key) } else { // Playlist // Get playlist Id } - // re-think way to handle playlist downloads, - // use old method in mvp where it writes the information to a file and then reads it back to update the database - - // Works ish - // download := FlatSingleDownload("archive.txt", "ids.txt", "names.txt", "duration.txt", "playlist_title.txt", "playlist_id.txt", "https://www.youtube.com/watch?v=s-uEFHxZ_nE", "output.log", "error.log", "opus") - - // Handle download result - // if !download { - // fmt.Println("Failed to download playlist") - // } else { - // fmt.Println("Playlist downloaded successfully") - // } - // Clean up files - for _, file := range filesToDelete { - err := os.RemoveAll(file) - if err != nil { - fmt.Printf("Failed to delete file: %s\n", file) - } else { - fmt.Printf("Deleted: %s\n", file) - } - } + cleanUopFiles(filesToDelete) } } @@ -185,3 +202,14 @@ func HandleError(err error) { fmt.Println(err.Error()) } } + +func cleanUopFiles(files []string) { + for _, file := range files { + err := os.RemoveAll(file) + if err != nil { + fmt.Printf("Failed to delete file: %s\n", file) + } else { + fmt.Printf("Deleted: %s\n", file) + } + } +} From 3b3fb2f0c72f432d97d3216c2d887d8f2fe914d7 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Tue, 10 Mar 2026 21:24:58 +0100 Subject: [PATCH 49/77] Update log.go --- src/backend/supabase/service/audio/log.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go index 9d5bf67..ee4350e 100644 --- a/src/backend/supabase/service/audio/log.go +++ b/src/backend/supabase/service/audio/log.go @@ -28,11 +28,11 @@ func (l *YtdlpLogger) CreateNewLog(title string) (*YtdlpOutputLog, error) { return &YtdlpOutputLog{ Id: lastinsertedId, ProgressState: int(Created), - Title: &title, + Title: title, }, nil } func (l *YtdlpLogger) UpdateLog(log *YtdlpOutputLog) error { - _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, OutputBase64 = $2, ErrorOutputBase64 = $3, ProgressState = $4 WHERE id = $5", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.Id) + _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, OutputBase64 = $2, ErrorOutputBase64 = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.FinishedAtUtc, log.Id) return err } From ca63a5f25ea4553e7ca627c448d8f6658d5cba71 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Wed, 11 Mar 2026 20:53:21 +0100 Subject: [PATCH 50/77] Update docker-compose.yml --- src/backend/supabase/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/supabase/docker-compose.yml b/src/backend/supabase/docker-compose.yml index a5771c5..255b543 100644 --- a/src/backend/supabase/docker-compose.yml +++ b/src/backend/supabase/docker-compose.yml @@ -419,6 +419,8 @@ services: QUEUE_NAME: audiopipe-input STORAGE_URL: http://kong:8000/storage/v1 STORAGE_KEY: ${SERVICE_ROLE_KEY} + AUDIO_BUCKET_ID: audio-files + IMAGE_BUCKET_ID: image-files # Comment out everything below this point if you are using an external Postgres database db: From 0fdf9059301c6a103ed9c67d98d85ad81a9ea532 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Wed, 11 Mar 2026 20:53:35 +0100 Subject: [PATCH 51/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index c8e4a65..b97aa1a 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -7,13 +7,16 @@ import ( ) type IStorageService interface { + EnsureBucketsExists() UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error) } type StorageService struct { IStorageService - client *storage.Client + client *storage.Client + audioBucketId string + imageBucketId string } func NewStorageService() *StorageService { @@ -33,11 +36,35 @@ func NewStorageService() *StorageService { panic("STORAGE_KEY environment variable is not set") } + audioBucketId := os.Getenv("AUDIO_BUCKET_ID") + + if audioBucketId == "" { + panic("AUDIO_BUCKET_ID environment variable is not set") + } + + imageBucketId := os.Getenv("IMAGE_BUCKET_ID") + + if imageBucketId == "" { + panic("IMAGE_BUCKET_ID environment variable is not set") + } + storageClient := storage.NewClient(storageUrl, storageKey, nil) return &StorageService{ - client: storageClient, + client: storageClient, + audioBucketId: audioBucketId, + imageBucketId: imageBucketId, + } +} + +func (s *StorageService) EnsureBucketsExists() { + options := storage.BucketOptions{ + Public: true, } + // This will fail incase bucket already existss after the first run + // move these into the database insetad???? + s.client.CreateBucket(s.audioBucketId, options) + s.client.CreateBucket(s.imageBucketId, options) } func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) { @@ -55,7 +82,7 @@ func (s *StorageService) UploadAudioFile(filePath string, fileName string) (stor ContentType: &contentType, Upsert: &upsert, } - response, err := s.client.UploadFile("audio-files", fileName, file, *options) + response, err := s.client.UploadFile(s.audioBucketId, fileName, file, *options) if err != nil { return storage.FileUploadResponse{}, err @@ -78,7 +105,7 @@ func (s *StorageService) UploadImageFile(filePath string, fileName string) (stor ContentType: &contentType, Upsert: &upsert, } - response, err := s.client.UploadFile("image-files", fileName, file, *options) + response, err := s.client.UploadFile(s.imageBucketId, fileName, file, *options) if err != nil { return storage.FileUploadResponse{}, err From 91df9d6d279fbc7f413d8a52a27209f94820f0d8 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Wed, 11 Mar 2026 20:53:43 +0100 Subject: [PATCH 52/77] Update 0 initial.sql --- .../service/migration/scripts/0 initial.sql | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 387b6e6..16b4629 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -20,41 +20,50 @@ GRANT ALL ON ALL SEQUENCES IN SCHEMA Librebeats TO service_role; -- Create the audio processing queue SELECT * FROM pgmq.create('audiopipe-input'); --- Example insert into the queue --- SELECT * FROM pgmq.send('audiopipe-input', '{"key": "path/to/audio/file.mp3", "metadata": {"artist": "Artist Name", "album": "Album Name"}}', 0); - CREATE TABLE IF NOT EXISTS Librebeats.Audio ( Id SERIAL PRIMARY KEY, - SourceId TEXT NOT NULL, - SourceName TEXT NOT NULL, - StorageLocation TEXT, - ThumbnailLocation TEXT, + AudioLocation TEXT NOT NULL, + ThumbnailLocation TEXT NOT NULL, DownloadCount INT NOT NULL DEFAULT 0, - CreatedAtUtc TIMESTAMP NOT NULL DEFAULT NOW() + CreatedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); ALTER TABLE Librebeats.Audio ENABLE ROW LEVEL SECURITY; --- ONLY authenticated users can access all the Audio table -CREATE POLICY "Authenticated users can access all audio" ON Librebeats.Audio +CREATE TABLE IF NOT EXISTS Librebeats.Song ( + Id SERIAL PRIMARY KEY, + AudioId SERIAL NOT NULL REFERENCES Librebeats.Audio(Id) ON DELETE CASCADE, + Title TEXT NOT NULL, + Artist TEXT NOT NULL, + Album TEXT NOT NULL, + Tags TEXT NOT NULL, + StreamingUrl TEXT NOT NULL, + ThumbnailUrl TEXT NOT NULL, +); + +ALTER TABLE Librebeats.Song ENABLE ROW LEVEL SECURITY; + +-- ONLY authenticated users can access songs +CREATE POLICY "Authenticated users can access all songs" ON Librebeats.Song FOR SELECT TO authenticated USING (true); + CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( Id INT PRIMARY KEY, Title TEXT NOT NULL, ProgressState INT NOT NULL, OutputBase64 TEXT NOT NULL, ErrorOutputBase64 TEXT, - StartedAtUtc TIMESTAMP NOT NULL DEFAULT NOW(), - FinishedAtUtc TIMESTAMP + StartedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FinishedAtUtc TIMESTAMP WITH TIME ZONE ); ALTER TABLE Librebeats.YtdlpOutputLog ENABLE ROW LEVEL SECURITY; -- Only service role can access the YtdlpOutputLog table -CREATE POLICY "Service role can access YtdlpOutputLog" ON Librebeats.YtdlpOutputLog +CREATE POLICY "Authenticated users can access YtdlpOutputLog" ON Librebeats.YtdlpOutputLog FOR SELECT TO authenticated USING (true); From 79e78fb85b759ed0234ca1492d59ff27f0e6d30f Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 12 Mar 2026 21:10:20 +0100 Subject: [PATCH 53/77] Update log.go --- src/backend/supabase/service/audio/log.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go index ee4350e..eb79a05 100644 --- a/src/backend/supabase/service/audio/log.go +++ b/src/backend/supabase/service/audio/log.go @@ -33,6 +33,6 @@ func (l *YtdlpLogger) CreateNewLog(title string) (*YtdlpOutputLog, error) { } func (l *YtdlpLogger) UpdateLog(log *YtdlpOutputLog) error { - _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, OutputBase64 = $2, ErrorOutputBase64 = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.OutputLogBase64, log.ErrorOutputLogBase64, log.ProgressState, log.FinishedAtUtc, log.Id) + _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, Output = $2, ErrorOutput = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.OutputLog, log.ErrorOutputLog, log.ProgressState, log.FinishedAtUtc, log.Id) return err } From e30f43d023455851c22092e0b0800488401b9f41 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 12 Mar 2026 21:10:28 +0100 Subject: [PATCH 54/77] Update main.go --- src/backend/supabase/service/audio/main.go | 40 ++++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index a1123c9..432ab12 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -11,6 +11,7 @@ import ( const ( SleepTimeInSeconds = 5 + StorageLocation = "/app/temp" ) func main() { @@ -23,6 +24,8 @@ func main() { // Storage var storage = NewStorageService() + storage.EnsureBucketsExists() + var logger = NewYtdlpLogger() for true { @@ -40,8 +43,8 @@ func main() { if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to unmarshal message body for message id: %d", audioQueueMessage.Id)) - log.OutputLogBase64 = string(audioQueueMessage.Message) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.OutputLog = string(audioQueueMessage.Message) + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) logger.UpdateLog(log) fmt.Printf("Failed to unmarshal message body for message id: %d\n Error: %s\n", audioQueueMessage.Id, err.Error()) @@ -52,8 +55,7 @@ func main() { sourceUrl := string(messageBody["url"].(string)) isPlaylist := strings.Contains(sourceUrl, "playlist?") - basePath := "/app/temp" - outputLocation := fmt.Sprintf("%s/%d", basePath, audioQueueMessage.Id) + outputLocation := fmt.Sprintf("%s/%d", StorageLocation, audioQueueMessage.Id) idsFile := fmt.Sprintf("%s/ids.txt", outputLocation) namesFile := fmt.Sprintf("%s/names.txt", outputLocation) durationFile := fmt.Sprintf("%s/duration.txt", outputLocation) @@ -73,11 +75,11 @@ func main() { outputLocation, } - if !pathExists(basePath) { - err := os.Mkdir(basePath, fs.ModePerm|fs.ModeDir) + if !pathExists(StorageLocation) { + err := os.Mkdir(StorageLocation, fs.ModePerm|fs.ModeDir) if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create base directory for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) @@ -89,7 +91,7 @@ func main() { err := os.Mkdir(outputLocation, fs.ModePerm|fs.ModeDir) if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create output directory for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) @@ -104,8 +106,8 @@ func main() { if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to download single video for message id: %d", audioQueueMessage.Id)) - log.OutputLogBase64 = string(audioQueueMessage.Message) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.OutputLog = string(audioQueueMessage.Message) + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) @@ -118,7 +120,7 @@ func main() { if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to read IDs file for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) @@ -127,31 +129,39 @@ func main() { } audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, ids[0]) - imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, ids[0]) + imageFilePath := fmt.Sprintf("%s/%s.jpeg", outputLocation, ids[0]) // Upload to storage audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", ids[0])) if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload audio file %s for message id: %d", audioFilePath, audioQueueMessage.Id)) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.OutputLog = audioUploadResponse.Message + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) + cleanUopFiles(filesToDelete) + continue } - imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpg", ids[0])) + imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpeg", ids[0])) if err != nil { log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload image file %s file for message id: %d", imageFilePath, audioQueueMessage.Id)) - log.ErrorOutputLogBase64 = fmt.Sprintf("Error: %s", err.Error()) + log.OutputLog = imageUploadResponse.Error + log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) log.ProgressState = int(Failed) log.FinishedAtUtc = time.Now() logger.UpdateLog(log) + cleanUopFiles(filesToDelete) + continue } fmt.Printf("Audio storage location: %s\n", audioUploadResponse.Key) fmt.Printf("Image storage location: %s\n", imageUploadResponse.Key) + + // update database with entry.... } else { // Playlist // Get playlist Id From ba4ad9946dd281276a6b3ecbefe38c3a0b94a451 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 12 Mar 2026 21:10:36 +0100 Subject: [PATCH 55/77] Update models.go --- src/backend/supabase/service/audio/models.go | 32 +++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go index ffa981c..6bca4b7 100644 --- a/src/backend/supabase/service/audio/models.go +++ b/src/backend/supabase/service/audio/models.go @@ -27,21 +27,25 @@ type AudioProcessingMessage struct { } type Audio struct { - Id uuid.UUID `db:"id"` - SourceId string `db:"source_id"` - SourceName *string `db:"source_name"` // nullable - StorageLocation string `db:"storage_location"` - ThumbnailLocation *string `db:"thumbnail_location"` // nullable - DownloadCount int `db:"download_count"` - CreatedAtUtc time.Time `db:"created_at_utc"` + Id int `json:"id" db:"id"` + Title string `json:"title" db:"title"` + Artist string `json:"artist" db:"artist"` + Album string `json:"album" db:"album"` + AudioLocation string `json:"audioLocation" db:"audio_location"` + ThumbnailLocation string `json:"thumbnailLocation" db:"thumbnail_location"` + StreamingURL string `json:"streamingUrl" db:"streaming_url"` + ThumbnailURL string `json:"thumbnailUrl" db:"thumbnail_url"` + DownloadCount int `json:"downloadCount" db:"download_count"` + CreatedAtUTC time.Time `json:"createdAtUtc" db:"created_at_utc"` } type YtdlpOutputLog struct { - Id uuid.UUID `db:"id"` - AudioId uuid.UUID `db:"audio_id"` - ProgressState int `db:"progress_state"` - Title *string `db:"title"` // nullable - OutputLogBase64 *string `db:"output_log"` // nullable - ErrorOutputLogBase64 *string `db:"error_output_log"` // nullable - CreatedAtUtc time.Time `db:"created_at_utc"` + Id uuid.UUID `db:"id"` + AudioId uuid.UUID `db:"audio_id"` + ProgressState int `db:"progress_state"` + Title string `db:"title"` // nullable + OutputLog string `db:"output_log"` // nullable + ErrorOutputLog string `db:"error_output_log"` // nullable + CreatedAtUtc time.Time `db:"created_at_utc"` + FinishedAtUtc time.Time `db:"finished_at_utc"` // nullable } From 5ec621a26aaf5cc3e89b174b63d1a3130e5484ec Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 12 Mar 2026 21:10:46 +0100 Subject: [PATCH 56/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index b97aa1a..bc7dcba 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -8,7 +8,9 @@ import ( type IStorageService interface { EnsureBucketsExists() + GetAudioPublicUrl(filename string) storage.SignedUrlResponse UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) + GetImagePublicUrl(filename string) storage.SignedUrlResponse UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error) } @@ -57,14 +59,38 @@ func NewStorageService() *StorageService { } } -func (s *StorageService) EnsureBucketsExists() { - options := storage.BucketOptions{ - Public: true, +func (s *StorageService) GetAudioPublicUrl(filePath string) storage.SignedUrlResponse { + return getPublicUrl(s, s.audioBucketId, filePath) +} + +func (s *StorageService) GetImagePublicUrl(filePath string) storage.SignedUrlResponse { + return getPublicUrl(s, s.imageBucketId, filePath) +} + +func getPublicUrl(storageService *StorageService, bucketId string, filePath string) storage.SignedUrlResponse { + options := storage.UrlOptions{ + Download: true, } + return storageService.client.GetPublicUrl(bucketId, filePath, options) +} + +func (s *StorageService) EnsureBucketsExists() { + // This will fail incase bucket already existss after the first run // move these into the database insetad???? - s.client.CreateBucket(s.audioBucketId, options) - s.client.CreateBucket(s.imageBucketId, options) + s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{ + Public: true, + AllowedMimeTypes: []string{ + "audio/ogg", + }, + }) + + s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{ + Public: true, + AllowedMimeTypes: []string{ + "image/jpeg", + }, + }) } func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) { From bdc031bab77c8a7742f1747909be064a907e74db Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 12 Mar 2026 21:10:58 +0100 Subject: [PATCH 57/77] Update 0 initial.sql --- .../supabase/service/migration/scripts/0 initial.sql | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 16b4629..7fe0bcb 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -22,6 +22,7 @@ SELECT * FROM pgmq.create('audiopipe-input'); CREATE TABLE IF NOT EXISTS Librebeats.Audio ( Id SERIAL PRIMARY KEY, + Source TEXT NOT NULL, AudioLocation TEXT NOT NULL, ThumbnailLocation TEXT NOT NULL, DownloadCount INT NOT NULL DEFAULT 0, @@ -35,12 +36,13 @@ CREATE TABLE IF NOT EXISTS Librebeats.Song ( AudioId SERIAL NOT NULL REFERENCES Librebeats.Audio(Id) ON DELETE CASCADE, Title TEXT NOT NULL, Artist TEXT NOT NULL, - Album TEXT NOT NULL, Tags TEXT NOT NULL, StreamingUrl TEXT NOT NULL, ThumbnailUrl TEXT NOT NULL, ); + + ALTER TABLE Librebeats.Song ENABLE ROW LEVEL SECURITY; -- ONLY authenticated users can access songs @@ -54,8 +56,8 @@ CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( Id INT PRIMARY KEY, Title TEXT NOT NULL, ProgressState INT NOT NULL, - OutputBase64 TEXT NOT NULL, - ErrorOutputBase64 TEXT, + Output TEXT NOT NULL, + ErrorOutput TEXT, StartedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FinishedAtUtc TIMESTAMP WITH TIME ZONE ); From 1f15b5bf1ae3a18b9894946f22524cc72c63577f Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Fri, 13 Mar 2026 23:54:55 +0100 Subject: [PATCH 58/77] Update Dockerfile --- src/backend/supabase/service/audio/Dockerfile | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile index f547906..c565b7f 100644 --- a/src/backend/supabase/service/audio/Dockerfile +++ b/src/backend/supabase/service/audio/Dockerfile @@ -2,39 +2,31 @@ FROM golang:1.26-alpine AS builder WORKDIR /app -# Copy go mod files first for better layer caching COPY go.mod go.sum* ./ RUN go mod download -# Copy source code and build COPY . . -RUN go build -o main +# Build a static binary (no libc dependencies) +RUN CGO_ENABLED=0 go build -o main -# Stage 2: Final image -FROM ubuntu:24.04 +# Stage 2: Final image (Alpine for minimal size) +FROM alpine:latest WORKDIR /app -# Combine apt-get commands to reduce layers and cleanup in same layer -RUN apt-get update && apt-get install -y \ - ffmpeg \ - wget \ - curl \ - unzip \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Install required system packages (without npm) +# --no-install-recommends avoids pulling extra packages +RUN apk add --no-cache ffmpeg wget curl unzip -# Install yt-dlp +# Install yt-dlp and deno in one layer, cleaning up as we go RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp \ - && chmod +x /usr/local/bin/yt-dlp - -# Install deno -RUN wget -qO- https://deno.land/install.sh | sh - -# Set path for deno -ENV PATH="/root/.deno/bin:${PATH}" - -# Copy binary and config from builder + && chmod +x /usr/local/bin/yt-dlp \ + && wget https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \ + && unzip deno-x86_64-unknown-linux-gnu.zip \ + && chmod +x deno \ + && mv deno /usr/local/bin/deno \ + && rm deno-x86_64-unknown-linux-gnu.zip + +# Copy the static binary and config COPY --from=builder /app/main . COPY cookies.txt /app/cookies.txt From 7ee86b0ef1afd5d20b497cfbe47e8ea7dc4e84fd Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Fri, 13 Mar 2026 23:55:26 +0100 Subject: [PATCH 59/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index bc7dcba..fbce62e 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -23,7 +23,6 @@ type StorageService struct { func NewStorageService() *StorageService { // Will throw an error if its missing a method implementation from interface - // will throw a compile time error var _ IStorageService = (*StorageService)(nil) storageUrl := os.Getenv("STORAGE_URL") From 88fea9d5740be6f9b4121768c9eff75047e75ec4 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:30:17 +0100 Subject: [PATCH 60/77] Update docker-compose.yml --- src/backend/supabase/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/supabase/docker-compose.yml b/src/backend/supabase/docker-compose.yml index 255b543..fa5b8bd 100644 --- a/src/backend/supabase/docker-compose.yml +++ b/src/backend/supabase/docker-compose.yml @@ -414,6 +414,8 @@ services: condition: service_completed_successfully db: condition: service_healthy + storage: + condition: service_healthy environment: POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres QUEUE_NAME: audiopipe-input From 590623521860c24877b480bf7fdd8bed3dd568b2 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:30:26 +0100 Subject: [PATCH 61/77] Update database.go --- .../supabase/service/audio/database.go | 148 +++++------------- 1 file changed, 39 insertions(+), 109 deletions(-) diff --git a/src/backend/supabase/service/audio/database.go b/src/backend/supabase/service/audio/database.go index 64fed74..8f28bf4 100644 --- a/src/backend/supabase/service/audio/database.go +++ b/src/backend/supabase/service/audio/database.go @@ -2,168 +2,98 @@ package main import ( "context" - "errors" - "fmt" "os" - "strings" + "time" - "github.com/google/uuid" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" ) -const ReturningIdParameter = "RETURNING" - -var DbInstancePool *pgxpool.Pool - -type BaseTable struct { - Pool *pgxpool.Pool -} - -func NewBaseTableInstance() BaseTable { - return BaseTable{ - Pool: DbInstancePool, - } +type ILibreDb interface { + NewRawAudioEntry(source string, audioLocation string, thumbnailLocation string, durration int) (RawBeat, error) + NewBeatEntry(rawBeat *RawBeat, title string, arties string, tags string, streamingUrl string, thumbnailUrl string) error } -func CreateConnectionPool() { - databaseUrl := os.Getenv("POSTGRES_BACKEND_URL") - - pool, err := pgxpool.New(context.Background(), databaseUrl) - if err != nil { - panic(fmt.Sprintf("unable to create connection pool: %v", err)) - } - - // Test connection - err = pool.Ping(context.Background()) - if err != nil { - pool.Close() - panic(fmt.Sprintf("unable to ping database: %v", err)) - } - - DbInstancePool = pool +type LibreDb struct { + ILibreDb + ConnectionString string } -func (base *BaseTable) InsertWithReturningId(query string, params ...any) (lastInsertedId int, err error) { +func NewLibreDb() LibreDb { - if !strings.Contains(query, ReturningIdParameter) { - return -1, errors.New("Query does not contain RETURNING keyword") - } + var _ ILibreDb = (*LibreDb)(nil) - transaction, err := base.Pool.Begin(context.Background()) - if err != nil { - return -1, err - } + connectionString := os.Getenv("POSTGRES_BACKEND_URL") - statement, err := transaction.Prepare(context.Background(), "", query) - if err != nil { - transaction.Rollback(context.Background()) - return -1, err + if connectionString == "" { + panic("POSTGRES_BACKEND_URL environment variable is not set") } - defer transaction.Conn().Close(context.Background()) - - err = transaction.QueryRow(context.Background(), statement.SQL, params...).Scan(&lastInsertedId) - - if err != nil { - transaction.Rollback(context.Background()) - return -1, err + return LibreDb{ + ConnectionString: connectionString, } +} - err = transaction.Commit(context.Background()) +func (db *LibreDb) NewRawAudioEntry(source string, audioLocation string, thumbnailLocation string, durration int) (RawBeat, error) { + connection, err := pgx.Connect(context.Background(), db.ConnectionString) if err != nil { - transaction.Rollback(context.Background()) - return -1, err + return RawBeat{}, err } - return lastInsertedId, nil -} - -func (base *BaseTable) InsertWithReturningIdUUID(query string, params ...any) (lastInsertedId uuid.UUID, err error) { + statement, err := connection.Begin(context.Background()) - if !strings.Contains(query, ReturningIdParameter) { - return uuid.Nil, errors.New("Query does not contain RETURNING keyword") - } - - transaction, err := base.Pool.Begin(context.Background()) if err != nil { - return uuid.Nil, err + return RawBeat{}, err } - statement, err := transaction.Prepare(context.Background(), "", query) - if err != nil { - transaction.Rollback(context.Background()) - return uuid.Nil, err - } - defer transaction.Conn().Close(context.Background()) + lastinsertedId := -1 - err = transaction.QueryRow(context.Background(), statement.SQL, params...).Scan(&lastInsertedId) + err = statement.QueryRow(context.Background(), "INSERT INTO Librebeats.RawBeat (Source, AudioLocation, ThumbnailLocation, Durration) VALUES($1, $2, $3, $4) RETURNING Id", source, audioLocation, thumbnailLocation, durration). + Scan(&lastinsertedId) if err != nil { - transaction.Rollback(context.Background()) - return uuid.Nil, err + return RawBeat{}, err } - err = transaction.Commit(context.Background()) + err = statement.Commit(context.Background()) if err != nil { - transaction.Rollback(context.Background()) - return uuid.Nil, err + return RawBeat{}, err } - return lastInsertedId, nil + return RawBeat{ + Id: lastinsertedId, + Source: &source, + AudioLocation: &audioLocation, + ThumbnailLocation: &thumbnailLocation, + DownloadCount: 0, + CreatedAtUtc: time.Now(), + }, nil } -func (base *BaseTable) NonScalarQuery(query string, params ...any) (error error) { - - transaction, err := base.Pool.Begin(context.Background()) +func (db *LibreDb) NewBeatEntry(rawBeat *RawBeat, title string, arties string, tags string, streamingUrl string, thumbnailUrl string) error { + connection, err := pgx.Connect(context.Background(), db.ConnectionString) if err != nil { return err } - defer transaction.Conn().Close(context.Background()) - - statement, err := transaction.Prepare(context.Background(), "", query) + statement, err := connection.Begin(context.Background()) if err != nil { - transaction.Rollback(context.Background()) return err } - _, err = transaction.Exec(context.Background(), statement.SQL, params...) + _, err = statement.Exec(context.Background(), "INSERT INTO Librebeats.Beat (RawBeatId, Title, Artist, Tags, StreamingUrl, ThumbnailUrl) VALUES($1, $2, $3, $4, $5, $6) RETURNING Id", rawBeat.Id, title, arties, tags, streamingUrl, thumbnailUrl) if err != nil { - transaction.Rollback(context.Background()) return err } - err = transaction.Commit(context.Background()) + err = statement.Commit(context.Background()) if err != nil { - transaction.Rollback(context.Background()) return err } return nil } - -func (base *BaseTable) QueryRow(query string, params ...any) (pgx.Row, error) { - pool, err := base.Pool.Acquire(context.Background()) - - if err != nil { - return nil, err - } - - return pool.QueryRow(context.Background(), query, params...), nil -} - -func (base *BaseTable) QueryRows(query string) (pgx.Rows, error) { - pool, err := base.Pool.Acquire(context.Background()) - - if err != nil { - return nil, err - } - - return pool.Query(context.Background(), query, nil) -} From 99fa273603d7ab26e7a96f76e3afc26e04841876 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:30:59 +0100 Subject: [PATCH 62/77] Update Dockerfile --- src/backend/supabase/service/audio/Dockerfile | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile index c565b7f..336768e 100644 --- a/src/backend/supabase/service/audio/Dockerfile +++ b/src/backend/supabase/service/audio/Dockerfile @@ -1,30 +1,24 @@ -# Stage 1: Builder +# Stage 1: Builder (stays the same) FROM golang:1.26-alpine AS builder WORKDIR /app - COPY go.mod go.sum* ./ RUN go mod download - COPY . . -# Build a static binary (no libc dependencies) RUN CGO_ENABLED=0 go build -o main -# Stage 2: Final image (Alpine for minimal size) +# Stage 2: Final image with Alpine FROM alpine:latest WORKDIR /app -# Install required system packages (without npm) -# --no-install-recommends avoids pulling extra packages -RUN apk add --no-cache ffmpeg wget curl unzip +# Install required system packages +RUN apk add --no-cache ffmpeg -# Install yt-dlp and deno in one layer, cleaning up as we go -RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp \ - && chmod +x /usr/local/bin/yt-dlp \ - && wget https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \ - && unzip deno-x86_64-unknown-linux-gnu.zip \ - && chmod +x deno \ - && mv deno /usr/local/bin/deno \ - && rm deno-x86_64-unknown-linux-gnu.zip +# Download and install the OFFICIAL musl-compatible yt-dlp binary +RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_musllinux -O /usr/bin/yt-dlp \ + && chmod +x /usr/bin/yt-dlp + +# Install deno (using Alpine's package or musl build) +RUN apk add --no-cache deno # Copy the static binary and config COPY --from=builder /app/main . From 2b7b74b71f98c54016abc834b9e3238d0e48e726 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:31:07 +0100 Subject: [PATCH 63/77] Update log.go --- src/backend/supabase/service/audio/log.go | 82 ++++++++++++++++++----- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go index eb79a05..95953c4 100644 --- a/src/backend/supabase/service/audio/log.go +++ b/src/backend/supabase/service/audio/log.go @@ -1,38 +1,84 @@ package main -import "context" +import ( + "context" + "fmt" + "os" -type IYtdlpLogger interface { - CreateNewLog(title string) (*YtdlpOutputLog, error) - UpdateLog(log *YtdlpOutputLog) error + "github.com/jackc/pgx/v5" +) + +type IAudioOutputLogger interface { + CreateNewLog(title string) (AudioOutput, error) + UpdateLog(log *AudioOutput) error } -type YtdlpLogger struct { - IYtdlpLogger - database BaseTable +type AudioOutputLogger struct { + IAudioOutputLogger + ConnectionString string } -func NewYtdlpLogger() *YtdlpLogger { +func NewYtdlpLogger() AudioOutputLogger { // Will throw an error if its missing a method implementation from interface // will throw a compile time error - var _ IYtdlpLogger = (*YtdlpLogger)(nil) + var _ IAudioOutputLogger = (*AudioOutputLogger)(nil) + + connectionString := os.Getenv("POSTGRES_BACKEND_URL") + + if connectionString == "" { + panic("POSTGRES_BACKEND_URL environment variable is not set") + } - return &YtdlpLogger{} + return AudioOutputLogger{ + ConnectionString: connectionString, + } } -func (l *YtdlpLogger) CreateNewLog(title string) (*YtdlpOutputLog, error) { - lastinsertedId, err := l.database.InsertWithReturningIdUUID("INSERT INTO YtdlpOutputLog (Title, ProgressState) VALUES ($1, $2) RETURNING id", title, Created) +func (l *AudioOutputLogger) CreateNewLog(title string) (AudioOutput, error) { + connection, err := pgx.Connect(context.Background(), l.ConnectionString) + if err != nil { - return nil, err + return AudioOutput{}, err + } + + defer connection.Close(context.Background()) + + var lastinsertedId = 1 + + trans, err := connection.Begin(context.Background()) + + scanError := trans.QueryRow(context.Background(), "INSERT INTO Librebeats.AudioOutputLog (Title, ProgressState) VALUES ($1, $2) RETURNING id", title, Created).Scan(&lastinsertedId) + + if scanError != nil { + return AudioOutput{}, err } - return &YtdlpOutputLog{ + + trans.Commit(context.Background()) + + return AudioOutput{ Id: lastinsertedId, ProgressState: int(Created), - Title: title, + Title: &title, }, nil } -func (l *YtdlpLogger) UpdateLog(log *YtdlpOutputLog) error { - _, err := l.database.Pool.Exec(context.Background(), "UPDATE YtdlpOutputLog SET Title = $1, Output = $2, ErrorOutput = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.OutputLog, log.ErrorOutputLog, log.ProgressState, log.FinishedAtUtc, log.Id) - return err +func (l *AudioOutputLogger) UpdateLog(log *AudioOutput) error { + connection, err := pgx.Connect(context.Background(), l.ConnectionString) + + if err != nil { + return err + } + defer connection.Close(context.Background()) + + trans, err := connection.Begin(context.Background()) + + _, transError := trans.Exec(context.Background(), "UPDATE Librebeats.AudioOutputLog SET Title = $1, Output = $2, ErrorOutput = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.Output, log.ErrorOutput, log.ProgressState, log.FinishedAtUtc, log.Id) + + if transError != nil { + fmt.Println(transError.Error()) + } + + trans.Commit(context.Background()) + + return transError } From f6ae274f4cd359c8cb7241e71f9e95b60314ed2a Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:31:17 +0100 Subject: [PATCH 64/77] Update main.go --- src/backend/supabase/service/audio/main.go | 154 ++++++++++----------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index 432ab12..f38c8d2 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -3,20 +3,22 @@ package main import ( "encoding/json" "fmt" - "io/fs" "os" + "strconv" "strings" "time" ) const ( SleepTimeInSeconds = 5 - StorageLocation = "/app/temp" + StorageLocation = "/app/sessions" ) +var logger AudioOutputLogger = NewYtdlpLogger() + func main() { - // Setup database connection pool - CreateConnectionPool() + // db + var db = NewLibreDb() // Create queue listener var queueListener = *createQueueListener() @@ -26,8 +28,6 @@ func main() { storage.EnsureBucketsExists() - var logger = NewYtdlpLogger() - for true { audioQueueMessage, _ := listenForMessage(&queueListener) @@ -42,12 +42,7 @@ func main() { err := json.Unmarshal(audioQueueMessage.Message, &messageBody) if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to unmarshal message body for message id: %d", audioQueueMessage.Id)) - log.OutputLog = string(audioQueueMessage.Message) - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - logger.UpdateLog(log) - fmt.Printf("Failed to unmarshal message body for message id: %d\n Error: %s\n", audioQueueMessage.Id, err.Error()) + ErrorLog(fmt.Sprintf("Failed to unmarshal message body for message id: %d", audioQueueMessage.Id), string(audioQueueMessage.Message), fmt.Sprintf("Error: %s", err.Error())) continue } @@ -55,7 +50,7 @@ func main() { sourceUrl := string(messageBody["url"].(string)) isPlaylist := strings.Contains(sourceUrl, "playlist?") - outputLocation := fmt.Sprintf("%s/%d", StorageLocation, audioQueueMessage.Id) + outputLocation := fmt.Sprintf("%s/run_%d", StorageLocation, audioQueueMessage.Id) idsFile := fmt.Sprintf("%s/ids.txt", outputLocation) namesFile := fmt.Sprintf("%s/names.txt", outputLocation) durationFile := fmt.Sprintf("%s/duration.txt", outputLocation) @@ -75,93 +70,91 @@ func main() { outputLocation, } - if !pathExists(StorageLocation) { - err := os.Mkdir(StorageLocation, fs.ModePerm|fs.ModeDir) + if !tryCreateDirectory(StorageLocation) || + !tryCreateDirectory(outputLocation) { + continue + } + + // split off between playlist and single download + if !isPlaylist { + // Single + _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, sourceUrl, logOutput, logOutputError, "opus") + if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create base directory for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) + errorLog, _ := readFile(logOutputError) + ErrorLog("FlatSingleDownload had an error", fmt.Sprintf("Failed to download url: %s", string(messageBody["url"].(string))), errorLog) + cleanUopFiles(filesToDelete) continue } - } - if !pathExists(outputLocation) { - err := os.Mkdir(outputLocation, fs.ModePerm|fs.ModeDir) - if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to create output directory for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) + if !fileExists(idsFile) || + !fileExists(namesFile) || + !fileExists(durationFile) { + // Failed to creat needed files, check errors + errorLog, _ := readFile(logOutputError) + ErrorLog("Error Ytdlp did not create needed files", fmt.Sprintf("Failed to download url: %s", string(messageBody["url"].(string))), errorLog) + cleanUopFiles(filesToDelete) continue } - } - // split off between playlist and single download - if !isPlaylist { - // Single - _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, playlistTitleFile, playlistIdFile, sourceUrl, logOutput, logOutputError, "opus") + id, err := readFile(idsFile) + name, err := readFile(namesFile) + duration, err := readFile(durationFile) + + fmt.Println(fmt.Sprintf("%s %s %s", id, name, duration)) + + audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, id) + imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, id) + + fmt.Println(audioFilePath) + fmt.Println(imageFilePath) + + // Upload to storage + audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", id)) if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to download single video for message id: %d", audioQueueMessage.Id)) - log.OutputLog = string(audioQueueMessage.Message) - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) - fmt.Printf("Failed to download single video for message id: %d\n Error: %s\n", audioQueueMessage.Id, err.Error()) + ErrorLog(fmt.Sprintf("Failed to upload audio file %s for message id: %d", audioFilePath, audioQueueMessage.Id), + string(audioQueueMessage.Message), + fmt.Sprintf("Error: %s", err.Error())) cleanUopFiles(filesToDelete) continue } - ids, err := readLines(idsFile) + imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpeg", id)) if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to read IDs file for message id: %d", audioQueueMessage.Id)) - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) + ErrorLog(fmt.Sprintf("Failed to upload image file %s file for message id: %d", imageFilePath, + audioQueueMessage.Id), + imageUploadResponse.Error, + fmt.Sprintf("Error: %s", err.Error())) cleanUopFiles(filesToDelete) continue } - audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, ids[0]) - imageFilePath := fmt.Sprintf("%s/%s.jpeg", outputLocation, ids[0]) + audioStorageLocation := audioUploadResponse.Key + imageStorageLocation := imageUploadResponse.Key - // Upload to storage - audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", ids[0])) + // update database with entry.... + _dur, err := strconv.Atoi(duration) + rawAudio, err := db.NewRawAudioEntry(sourceUrl, audioStorageLocation, imageStorageLocation, _dur) if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload audio file %s for message id: %d", audioFilePath, audioQueueMessage.Id)) - log.OutputLog = audioUploadResponse.Message - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) + ErrorLog("Failed to create new RawAudio entry", "", err.Error()) cleanUopFiles(filesToDelete) continue } - imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpeg", ids[0])) + audioPublicUrl := storage.GetAudioPublicUrl(audioStorageLocation) + thumbnailPublicUrl := storage.GetImagePublicUrl(imageStorageLocation) + + err = db.NewBeatEntry(&rawAudio, name, name, "", audioPublicUrl.SignedURL, thumbnailPublicUrl.SignedURL) if err != nil { - log, _ := logger.CreateNewLog(fmt.Sprintf("Failed to upload image file %s file for message id: %d", imageFilePath, audioQueueMessage.Id)) - log.OutputLog = imageUploadResponse.Error - log.ErrorOutputLog = fmt.Sprintf("Error: %s", err.Error()) - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(log) + ErrorLog("Failed to create a new Beat entry", "", err.Error()) cleanUopFiles(filesToDelete) continue } - fmt.Printf("Audio storage location: %s\n", audioUploadResponse.Key) - fmt.Printf("Image storage location: %s\n", imageUploadResponse.Key) - - // update database with entry.... } else { // Playlist // Get playlist Id @@ -176,7 +169,7 @@ func listenForMessage(queue *QueueListener) (*AudioPipeQueueMessage, error) { audioQueueMessage, err := queue.Pop() if err != nil || audioQueueMessage == nil { - HandleError(err) + //ErrorLog(err) sleep() return nil, err } @@ -207,19 +200,12 @@ func sleep() { time.Sleep(SleepTimeInSeconds * time.Second) } -func HandleError(err error) { - if err != nil { - fmt.Println(err.Error()) - } -} - -func cleanUopFiles(files []string) { - for _, file := range files { - err := os.RemoveAll(file) - if err != nil { - fmt.Printf("Failed to delete file: %s\n", file) - } else { - fmt.Printf("Deleted: %s\n", file) - } - } +func ErrorLog(title string, outputlog string, errorOutput string) { + log, _ := logger.CreateNewLog(fmt.Sprintf("Error: %s", title)) + log.Output = &outputlog + log.ErrorOutput = &errorOutput + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(&log) + fmt.Println(title) } From ac681bad6665169ef20e4106eab291c8b7b09ba1 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:31:22 +0100 Subject: [PATCH 65/77] Update models.go --- src/backend/supabase/service/audio/models.go | 48 +++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go index 6bca4b7..18ff1f7 100644 --- a/src/backend/supabase/service/audio/models.go +++ b/src/backend/supabase/service/audio/models.go @@ -3,8 +3,6 @@ package main import ( "encoding/json" "time" - - "github.com/google/uuid" ) type ProgressState int @@ -26,26 +24,32 @@ type AudioProcessingMessage struct { Url string `json:"url"` } -type Audio struct { - Id int `json:"id" db:"id"` - Title string `json:"title" db:"title"` - Artist string `json:"artist" db:"artist"` - Album string `json:"album" db:"album"` - AudioLocation string `json:"audioLocation" db:"audio_location"` - ThumbnailLocation string `json:"thumbnailLocation" db:"thumbnail_location"` - StreamingURL string `json:"streamingUrl" db:"streaming_url"` - ThumbnailURL string `json:"thumbnailUrl" db:"thumbnail_url"` - DownloadCount int `json:"downloadCount" db:"download_count"` - CreatedAtUTC time.Time `json:"createdAtUtc" db:"created_at_utc"` +type Beat struct { + Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"` + RawBeatId int `json:"raw_beat_id" db:"raw_beat_id" gorm:"not null;index"` // foreign key to Audio/RawBeat + Title *string `json:"title" db:"title" gorm:"not null"` + Artist *string `json:"artist" db:"artist" gorm:"not null"` + Tags *string `json:"tags" db:"tags" gorm:"not null"` + StreamingURL *string `json:"streaming_url" db:"streaming_url" gorm:"not null"` + ThumbnailURL *string `json:"thumbnail_url" db:"thumbnail_url" gorm:"not null"` +} + +type RawBeat struct { + Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"` + Source *string `json:"source" db:"source" gorm:"not null"` + AudioLocation *string `json:"audio_location" db:"audio_location" gorm:"not null"` + ThumbnailLocation *string `json:"thumbnail_location" db:"thumbnail_location" gorm:"not null"` + DownloadCount int `json:"download_count" db:"download_count" gorm:"not null;default:0"` + CreatedAtUtc time.Time `json:"created_at_utc" db:"created_at_utc" gorm:"not null;default:now()"` + Duration int `json:"duration" db:"duration" gorm:"not null;default 0"` } -type YtdlpOutputLog struct { - Id uuid.UUID `db:"id"` - AudioId uuid.UUID `db:"audio_id"` - ProgressState int `db:"progress_state"` - Title string `db:"title"` // nullable - OutputLog string `db:"output_log"` // nullable - ErrorOutputLog string `db:"error_output_log"` // nullable - CreatedAtUtc time.Time `db:"created_at_utc"` - FinishedAtUtc time.Time `db:"finished_at_utc"` // nullable +type AudioOutput struct { + Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"` + Title *string `json:"title" db:"title" gorm:"not null"` + ProgressState int `json:"progress_state" db:"progress_state" gorm:"not null"` + Output *string `json:"output,omitempty" db:"output" gorm:"default:null"` // nullable + ErrorOutput *string `json:"error_output,omitempty" db:"error_output" gorm:"default:null"` // nullable + StartedAtUtc *time.Time `json:"started_at_utc" db:"started_at_utc" gorm:"not null;default:now()"` + FinishedAtUtc time.Time `json:"finished_at_utc,omitempty" db:"finished_at_utc" gorm:"default:null"` // nullable } From 3b53994e398342b7f54c14dcd3ea8c000ff80507 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:31:37 +0100 Subject: [PATCH 66/77] Update sourceHelper.go --- .../supabase/service/audio/sourceHelper.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go index 23baad5..88bbd66 100644 --- a/src/backend/supabase/service/audio/sourceHelper.go +++ b/src/backend/supabase/service/audio/sourceHelper.go @@ -33,7 +33,7 @@ func FlatPlaylistDownload( } proc, _err := os.StartProcess( - "/usr/local/bin/yt-dlp", + "/usr/bin/yt-dlp", []string{ "yt-dlp", "--force-ipv4", @@ -49,7 +49,7 @@ func FlatPlaylistDownload( "--ignore-errors", "--extractor-args=youtube:player_js_variant=tv", fmt.Sprintf("--cookies=%s", cookiesPath), - "--js-runtimes=deno:/home/admin/.deno/bin", + "--js-runtimes=deno:/usr/bin", "--remote-components=ejs:npm", url, }, @@ -80,22 +80,20 @@ func FlatSingleDownload( idsFileName string, namesFileName string, durationFileName string, - playlistTitleFileName string, - playlistIdFileName string, url string, logOutput string, logOutputError string, fileExtension string, ) (bool, error) { - Stdout, err := os.Create(logOutput) + Stdout, err := os.OpenFile(logOutput, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Println(err.Error()) return false, err } - Stderr, err := os.Create(logOutputError) + Stderr, err := os.OpenFile(logOutputError, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Println(err.Error()) @@ -103,7 +101,7 @@ func FlatSingleDownload( } proc, _err := os.StartProcess( - "/usr/local/bin/yt-dlp", + "/usr/bin/yt-dlp", []string{ "yt-dlp", "--force-ipv4", @@ -111,7 +109,7 @@ func FlatSingleDownload( "--extract-audio", "--audio-quality=0", fmt.Sprintf("--audio-format=%s", fileExtension), - "--convert-thumbnails=jpeg", + "--convert-thumbnails=jpg", "--force-ipv4", "--downloader=aria2c", "--no-keep-video", @@ -125,7 +123,7 @@ func FlatSingleDownload( // fmt.Sprintf("--download-archive=%s", archiveFileName), // not needed for now "--extractor-args=youtube:player_js_variant=tv", fmt.Sprintf("--cookies=%s", cookiesPath), - "--js-runtimes=deno:/root/.deno/bin", + "--js-runtimes=deno:/usr/bin/", "--remote-components=ejs:npm", url, }, From d2d715029ef87e02e867a5e2c9976541b70b2bec Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:31:42 +0100 Subject: [PATCH 67/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index fbce62e..e647460 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -77,19 +77,28 @@ func (s *StorageService) EnsureBucketsExists() { // This will fail incase bucket already existss after the first run // move these into the database insetad???? - s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{ + _, err := s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{ Public: true, AllowedMimeTypes: []string{ "audio/ogg", }, }) - s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{ + if err != nil { + ErrorLog("Failed to create audio bucket", "", err.Error()) + } + + _, err = s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{ Public: true, AllowedMimeTypes: []string{ "image/jpeg", }, }) + + if err != nil { + ErrorLog("Failed to create audio bucket", "", err.Error()) + } + } func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) { From c624900b3e040298b397d7d65218967ed75b76a3 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:32:05 +0100 Subject: [PATCH 68/77] Update util.go --- src/backend/supabase/service/audio/util.go | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/backend/supabase/service/audio/util.go b/src/backend/supabase/service/audio/util.go index c9de80e..0a1a7a5 100644 --- a/src/backend/supabase/service/audio/util.go +++ b/src/backend/supabase/service/audio/util.go @@ -2,9 +2,42 @@ package main import ( "bufio" + "errors" + "fmt" "os" + "strings" ) +func tryCreateDirectory(path string) bool { + err := ensureDir(path) + + if err != nil { + ErrorLog("Failed to create directory", "", err.Error()) + return false + } + + return true +} + +func ensureDir(dirName string) error { + err := os.Mkdir(dirName, os.ModeDir) + if err == nil { + return nil + } + if os.IsExist(err) { + // check that the existing path is a directory + info, err := os.Stat(dirName) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("path exists but is not a directory") + } + return nil + } + return err +} + func fileExists(path string) bool { _, err := os.Stat(path) // No error, file exists @@ -28,6 +61,15 @@ func readLines(path string) ([]string, error) { return lines, scanner.Err() } +func readFile(path string) (string, error) { + bytes, err := os.ReadFile(path) + + if err != nil { + return "", err + } + return strings.TrimSpace(string(bytes)), nil +} + func pathExists(path string) bool { info, err := os.Stat(path) if os.IsNotExist(err) { @@ -35,3 +77,14 @@ func pathExists(path string) bool { } return err == nil && (info.IsDir()) } + +func cleanUopFiles(files []string) { + for _, file := range files { + err := os.RemoveAll(file) + if err != nil { + fmt.Printf("Failed to delete file: %s\n", file) + } else { + fmt.Printf("Deleted: %s\n", file) + } + } +} From 839cba263736757553afc812a30ab174ebbb3ca2 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:32:36 +0100 Subject: [PATCH 69/77] Update Dockerfile --- .../supabase/service/migration/Dockerfile | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/backend/supabase/service/migration/Dockerfile b/src/backend/supabase/service/migration/Dockerfile index 9660f95..3aa0bd5 100644 --- a/src/backend/supabase/service/migration/Dockerfile +++ b/src/backend/supabase/service/migration/Dockerfile @@ -1,32 +1,36 @@ -# Stage 1: Build the Go app -FROM golang:1.25-alpine AS builder +# Stage 1: Build the Go application +# Use a specific Go version for reproducible builds (latest stable as of writing) +FROM golang:1.26-alpine AS builder -# Set environment variables for Go build -ENV GO111MODULE=on \ - CGO_ENABLED=0 \ +# Set environment variables for static linking and module support +ENV CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 WORKDIR /app -# Install Go dependencies +# Copy go.mod and go.sum first to leverage Docker caching COPY go.mod go.sum ./ RUN go mod download -# Copy the source code and build +# Copy the rest of the source code +# Use .dockerignore to exclude unnecessary files (e.g., .git, node_modules, etc.) COPY . . -RUN go build -o main . -# Stage 2: Final image -FROM alpine:3.19 +# Build the binary with optimizations: strip debug info and disable symbol table +RUN go build -ldflags="-s -w" -o main . -# Copy the built Go binary from the builder stage +# Stage 2: Create the final lightweight image +# Use a minimal Alpine base with a specific version for security and stability +FROM alpine:latest + +# Copy the compiled binary from the builder stage COPY --from=builder /app/main /app/main -# Set working directory and permissions +# Set working directory for the application WORKDIR /app -# COPY sql scripts +# Copy any required scripts, preserving executable permissions COPY scripts/* ./scripts/ # Run the binary From 8b0ac14bda435a4054cd01735c78894dce67df61 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Sun, 15 Mar 2026 17:32:46 +0100 Subject: [PATCH 70/77] Update 0 initial.sql --- .../service/migration/scripts/0 initial.sql | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 7fe0bcb..3acdf38 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -1,4 +1,5 @@ -- 1. Schema Setup +CREATE EXTENSION IF NOT EXISTS pgmq; CREATE SCHEMA IF NOT EXISTS Librebeats; -- 4. Internal Table @@ -20,52 +21,65 @@ GRANT ALL ON ALL SEQUENCES IN SCHEMA Librebeats TO service_role; -- Create the audio processing queue SELECT * FROM pgmq.create('audiopipe-input'); -CREATE TABLE IF NOT EXISTS Librebeats.Audio ( +CREATE TABLE IF NOT EXISTS Librebeats.RawBeat ( Id SERIAL PRIMARY KEY, Source TEXT NOT NULL, AudioLocation TEXT NOT NULL, ThumbnailLocation TEXT NOT NULL, - DownloadCount INT NOT NULL DEFAULT 0, - CreatedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + DownloadCount INT NOT NULL DEFAULT 0,--????????????????????? + CreatedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + Durration INTEGER NOT NULL, + CONSTRAINT unique_source UNIQUE (Source), + CONSTRAINT unique_audio_location UNIQUE (AudioLocation), + CONSTRAINT unique_thumbnail_location UNIQUE (ThumbnailLocation) + ); -ALTER TABLE Librebeats.Audio ENABLE ROW LEVEL SECURITY; +ALTER TABLE Librebeats.RawBeat ENABLE ROW LEVEL SECURITY; -CREATE TABLE IF NOT EXISTS Librebeats.Song ( +CREATE TABLE IF NOT EXISTS Librebeats.Beat ( Id SERIAL PRIMARY KEY, - AudioId SERIAL NOT NULL REFERENCES Librebeats.Audio(Id) ON DELETE CASCADE, + RawBeatId SERIAL NOT NULL REFERENCES Librebeats.RawBeat(Id) ON DELETE CASCADE, Title TEXT NOT NULL, Artist TEXT NOT NULL, Tags TEXT NOT NULL, StreamingUrl TEXT NOT NULL, ThumbnailUrl TEXT NOT NULL, + CONSTRAINT unique_streaming_url UNIQUE (StreamingUrl), + CONSTRAINT unique_thumbnail_url UNIQUE (ThumbnailUrl) ); - - -ALTER TABLE Librebeats.Song ENABLE ROW LEVEL SECURITY; +ALTER TABLE Librebeats.Beat ENABLE ROW LEVEL SECURITY; -- ONLY authenticated users can access songs -CREATE POLICY "Authenticated users can access all songs" ON Librebeats.Song +CREATE POLICY "Authenticated users can access all songs" ON Librebeats.Beat FOR SELECT TO authenticated USING (true); -CREATE TABLE IF NOT EXISTS Librebeats.YtdlpOutputLog ( - Id INT PRIMARY KEY, +CREATE TABLE IF NOT EXISTS Librebeats.AudioOutputLog ( + Id SERIAL PRIMARY KEY, Title TEXT NOT NULL, ProgressState INT NOT NULL, - Output TEXT NOT NULL, + Output TEXT NULL, ErrorOutput TEXT, StartedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FinishedAtUtc TIMESTAMP WITH TIME ZONE ); -ALTER TABLE Librebeats.YtdlpOutputLog ENABLE ROW LEVEL SECURITY; +ALTER TABLE Librebeats.AudioOutputLog ENABLE ROW LEVEL SECURITY; --- Only service role can access the YtdlpOutputLog table -CREATE POLICY "Authenticated users can access YtdlpOutputLog" ON Librebeats.YtdlpOutputLog +-- Only service role can access the AudioOutputLog table +CREATE POLICY "Authenticated users can access AudioOutputLog" ON Librebeats.AudioOutputLog FOR SELECT TO authenticated USING (true); + + +-- Index for foreign key performance +CREATE INDEX IF NOT EXISTS idx_beat_rawbeat_id ON Librebeats.Beat(RawBeatId); + + +-- Ensure future tables inherit grants +ALTER DEFAULT PRIVILEGES IN SCHEMA Librebeats GRANT ALL ON TABLES TO service_role; \ No newline at end of file From 4e6109934b100c0a00c4dca44fe18856b06c9d43 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 16 Mar 2026 21:17:49 +0100 Subject: [PATCH 71/77] Update 0 initial.sql --- src/backend/supabase/service/migration/scripts/0 initial.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 3acdf38..913f307 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -82,4 +82,5 @@ CREATE INDEX IF NOT EXISTS idx_beat_rawbeat_id ON Librebeats.Beat(RawBeatId); -- Ensure future tables inherit grants -ALTER DEFAULT PRIVILEGES IN SCHEMA Librebeats GRANT ALL ON TABLES TO service_role; \ No newline at end of file +ALTER DEFAULT PRIVILEGES IN SCHEMA Librebeats GRANT ALL ON TABLES TO service_role; + From f2cdb9a357b88a3ea1c1235ff5499c7d04628194 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 16 Mar 2026 21:17:54 +0100 Subject: [PATCH 72/77] Update util.go --- src/backend/supabase/service/audio/util.go | 48 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/service/audio/util.go b/src/backend/supabase/service/audio/util.go index 0a1a7a5..4c47170 100644 --- a/src/backend/supabase/service/audio/util.go +++ b/src/backend/supabase/service/audio/util.go @@ -6,13 +6,14 @@ import ( "fmt" "os" "strings" + "time" ) func tryCreateDirectory(path string) bool { err := ensureDir(path) if err != nil { - ErrorLog("Failed to create directory", "", err.Error()) + ErrorLog("Failed to create directory", "tryCreateDirectory", err.Error()) return false } @@ -88,3 +89,48 @@ func cleanUopFiles(files []string) { } } } + +func listenForMessage(queue *QueueListener) (*AudioPipeQueueMessage, error) { + audioQueueMessage, err := queue.Pop() + + if err != nil || audioQueueMessage == nil { + //ErrorLog(err) + sleep() + return nil, err + } + + return audioQueueMessage, nil +} + +func createQueueListener() *QueueListener { + connectionString := os.Getenv("POSTGRES_BACKEND_URL") + queueName := os.Getenv("QUEUE_NAME") + + if connectionString == "" { + panic("POSTGRES_BACKEND_URL environment variable is not set") + } + + if queueName == "" { + panic("QUEUE_NAME environment variable is not set") + } + + return &QueueListener{ + ConnectionString: connectionString, + QueueName: queueName, + } +} + +func sleep() { + fmt.Printf("Sleeping for %d seconds...\n", SleepTimeInSeconds) + time.Sleep(SleepTimeInSeconds * time.Second) +} + +func ErrorLog(title string, outputlog string, errorOutput string) { + log, _ := logger.CreateNewLog(fmt.Sprintf("Error: %s", title)) + log.Output = &outputlog + log.ErrorOutput = &errorOutput + log.ProgressState = int(Failed) + log.FinishedAtUtc = time.Now() + logger.UpdateLog(&log) + fmt.Println(title) +} From 7ce7a6ab047cc96d9e7143edf56c16f702e545af Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 16 Mar 2026 21:18:26 +0100 Subject: [PATCH 73/77] Update storage.go --- src/backend/supabase/service/audio/storage.go | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go index e647460..292210d 100644 --- a/src/backend/supabase/service/audio/storage.go +++ b/src/backend/supabase/service/audio/storage.go @@ -77,28 +77,35 @@ func (s *StorageService) EnsureBucketsExists() { // This will fail incase bucket already existss after the first run // move these into the database insetad???? - _, err := s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{ - Public: true, - AllowedMimeTypes: []string{ - "audio/ogg", - }, - }) + _, err := s.client.GetBucket(s.audioBucketId) if err != nil { - ErrorLog("Failed to create audio bucket", "", err.Error()) + _, err = s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{ + Public: true, + AllowedMimeTypes: []string{ + "audio/ogg", + }, + }) + + if err != nil { + ErrorLog("Failed to create audio bucket", "EnsureBucketsExists", err.Error()) + } } - _, err = s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{ - Public: true, - AllowedMimeTypes: []string{ - "image/jpeg", - }, - }) + _, err = s.client.GetBucket(s.imageBucketId) if err != nil { - ErrorLog("Failed to create audio bucket", "", err.Error()) + _, err = s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{ + Public: true, + AllowedMimeTypes: []string{ + "image/jpeg", + }, + }) + + if err != nil { + ErrorLog("Failed to create audio bucket", "EnsureBucketsExists", err.Error()) + } } - } func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) { From f70837b748468b479fbe1bb3f71fb657a45a1abd Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 16 Mar 2026 21:18:33 +0100 Subject: [PATCH 74/77] Update sourceHelper.go --- src/backend/supabase/service/audio/sourceHelper.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go index 88bbd66..ccab8a9 100644 --- a/src/backend/supabase/service/audio/sourceHelper.go +++ b/src/backend/supabase/service/audio/sourceHelper.go @@ -81,6 +81,7 @@ func FlatSingleDownload( namesFileName string, durationFileName string, url string, + tagsFileName string, logOutput string, logOutputError string, fileExtension string, @@ -117,6 +118,7 @@ func FlatSingleDownload( "--print-to-file", "%(id)s", idsFileName, "--print-to-file", "%(title)s", namesFileName, "--print-to-file", "%(duration)s", durationFileName, + "--print-to-file", "%(tags)s", tagsFileName, "--output", outputLocation + "/%(id)s.%(ext)s", "--concurrent-fragments=20", "--ignore-errors", From 8f4f5aae4d3f377cde9a09ba961467100c566d62 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Mon, 16 Mar 2026 21:18:42 +0100 Subject: [PATCH 75/77] Update main.go --- src/backend/supabase/service/audio/main.go | 61 ++-------------------- 1 file changed, 5 insertions(+), 56 deletions(-) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index f38c8d2..0b91afa 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -3,10 +3,8 @@ package main import ( "encoding/json" "fmt" - "os" "strconv" "strings" - "time" ) const ( @@ -56,6 +54,7 @@ func main() { durationFile := fmt.Sprintf("%s/duration.txt", outputLocation) playlistTitleFile := fmt.Sprintf("%s/playlist_title.txt", outputLocation) playlistIdFile := fmt.Sprintf("%s/playlist_id.txt", outputLocation) + tags := fmt.Sprintf("%s/tags.txt", outputLocation) logOutput := fmt.Sprintf("%s/output.log", outputLocation) logOutputError := fmt.Sprintf("%s/error.log", outputLocation) @@ -66,6 +65,7 @@ func main() { playlistTitleFile, playlistIdFile, logOutput, + tags, logOutputError, outputLocation, } @@ -78,7 +78,7 @@ func main() { // split off between playlist and single download if !isPlaylist { // Single - _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, sourceUrl, logOutput, logOutputError, "opus") + _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, sourceUrl, tags, logOutput, logOutputError, "opus") if err != nil { errorLog, _ := readFile(logOutputError) @@ -100,15 +100,9 @@ func main() { id, err := readFile(idsFile) name, err := readFile(namesFile) duration, err := readFile(durationFile) - - fmt.Println(fmt.Sprintf("%s %s %s", id, name, duration)) - audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, id) imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, id) - fmt.Println(audioFilePath) - fmt.Println(imageFilePath) - // Upload to storage audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", id)) @@ -139,7 +133,7 @@ func main() { rawAudio, err := db.NewRawAudioEntry(sourceUrl, audioStorageLocation, imageStorageLocation, _dur) if err != nil { - ErrorLog("Failed to create new RawAudio entry", "", err.Error()) + ErrorLog("Failed to create new RawAudio entry", sourceUrl, err.Error()) cleanUopFiles(filesToDelete) continue } @@ -150,7 +144,7 @@ func main() { err = db.NewBeatEntry(&rawAudio, name, name, "", audioPublicUrl.SignedURL, thumbnailPublicUrl.SignedURL) if err != nil { - ErrorLog("Failed to create a new Beat entry", "", err.Error()) + ErrorLog("Failed to create a new Beat entry", sourceUrl, err.Error()) cleanUopFiles(filesToDelete) continue } @@ -164,48 +158,3 @@ func main() { cleanUopFiles(filesToDelete) } } - -func listenForMessage(queue *QueueListener) (*AudioPipeQueueMessage, error) { - audioQueueMessage, err := queue.Pop() - - if err != nil || audioQueueMessage == nil { - //ErrorLog(err) - sleep() - return nil, err - } - - return audioQueueMessage, nil -} - -func createQueueListener() *QueueListener { - connectionString := os.Getenv("POSTGRES_BACKEND_URL") - queueName := os.Getenv("QUEUE_NAME") - - if connectionString == "" { - panic("POSTGRES_BACKEND_URL environment variable is not set") - } - - if queueName == "" { - panic("QUEUE_NAME environment variable is not set") - } - - return &QueueListener{ - ConnectionString: connectionString, - QueueName: queueName, - } -} - -func sleep() { - fmt.Printf("Sleeping for %d seconds...\n", SleepTimeInSeconds) - time.Sleep(SleepTimeInSeconds * time.Second) -} - -func ErrorLog(title string, outputlog string, errorOutput string) { - log, _ := logger.CreateNewLog(fmt.Sprintf("Error: %s", title)) - log.Output = &outputlog - log.ErrorOutput = &errorOutput - log.ProgressState = int(Failed) - log.FinishedAtUtc = time.Now() - logger.UpdateLog(&log) - fmt.Println(title) -} From 814f32db019d0ea782e02ff3e637482765f53f47 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 19 Mar 2026 21:39:48 +0100 Subject: [PATCH 76/77] Update 0 initial.sql --- .../service/migration/scripts/0 initial.sql | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql index 913f307..eee7234 100644 --- a/src/backend/supabase/service/migration/scripts/0 initial.sql +++ b/src/backend/supabase/service/migration/scripts/0 initial.sql @@ -79,8 +79,26 @@ CREATE POLICY "Authenticated users can access AudioOutputLog" ON Librebeats.Audi -- Index for foreign key performance CREATE INDEX IF NOT EXISTS idx_beat_rawbeat_id ON Librebeats.Beat(RawBeatId); - +CREATE INDEX IF NOT EXISTS idx_beat_title ON Librebeats.Beat(Title); +CREATE INDEX IF NOT EXISTS idx_beat_artist ON Librebeats.Beat(Artist); +CREATE INDEX IF NOT EXISTS idx_beat_tags ON Librebeats.Beat(Tags); -- Ensure future tables inherit grants ALTER DEFAULT PRIVILEGES IN SCHEMA Librebeats GRANT ALL ON TABLES TO service_role; +CREATE TABLE IF NOT EXISTS Librebeats.BeatMix ( + Id SERIAL PRIMARY KEY, + Title TEXT NOT NULL, + ThumbnailUrl TEXT NOT NULL, + CreatedOn TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT unique_beatmix_title UNIQUE(Title) +); + +CREATE INDEX IF NOT EXISTS idx_beatmix_title ON Librebeats.BeatMix(Title); + +CREATE TABLE IF NOT EXTENSION Librebeats.BeatMixBeat ( + BeatId SERIAL NOT NULL REFERENCES Librebeats.Beat(Id) ON DELETE CASCADE, + BeatMixId SERIAL NOT NULL REFERENCES Librebeats.BeatMix(Id) ON DELETE CASCADE, + PRIMARY KEY (BeatMixId, BeatMixBeatId) +); + From b5ff6d2c7c470a7c2ce43e0680d777fdda486b18 Mon Sep 17 00:00:00 2001 From: ABoredDeveloper Date: Thu, 19 Mar 2026 21:39:53 +0100 Subject: [PATCH 77/77] Update main.go --- src/backend/supabase/service/audio/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go index 0b91afa..fb24950 100644 --- a/src/backend/supabase/service/audio/main.go +++ b/src/backend/supabase/service/audio/main.go @@ -151,6 +151,7 @@ func main() { } else { // Playlist + // Get playlist Id }