Git Repository

miniaudiolib

miniaudiolib scannt lokale Musik- und Hörbuchordner, liest Audio-Metadaten und Cover, verwaltet Favoriten und Playlists und streamt Titel direkt an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/miniaudiolib.git

internal/musiclib/library.go

Zum Verzeichnis
package musiclib

import (
	"context"
	"database/sql"
	"errors"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

type Track struct {
	ID                                                                                int64 `json:"id"`
	Path, Filename, Title, Artist, AlbumArtist, Album, Genre, Codec, Container, Cover string
	Year, TrackNumber, DiscNumber, SampleRate, Channels                               int
	Duration                                                                          float64
	Bitrate, Size                                                                     int64
	Favorite                                                                          bool
	PlayCount                                                                         int
	PlaybackPosition                                                                  float64
}
type Album struct {
	ID                   int64
	Title, Artist, Cover string
	Year, Tracks         int
	Duration             float64
}
type Stats struct {
	Tracks, Artists, Albums int
	Duration                float64
}
type Audiobook struct {
	ID                 int64
	Path, Title, Cover string
	Chapters           int
	Duration           float64
	Completed          int
}
type Chapter struct {
	ID                                  int64 `json:"id"`
	Path, Filename, BookPath, BookTitle string
	Title, Container, Cover             string
	Duration, PlaybackPosition          float64
	Completed                           bool
}
type Playlist struct {
	ID       int64
	Name     string
	Tracks   int
	Duration float64
}
type Service struct{ db *sql.DB }

func New(db *sql.DB) *Service { return &Service{db} }

const cols = `id,path,filename,title,artist,album_artist,album,genre,year,track_number,disc_number,duration,codec,bitrate,sample_rate,channels,container,size,cover,favorite,play_count,playback_position`

func scan(s interface{ Scan(...any) error }) (Track, error) {
	var t Track
	e := s.Scan(&t.ID, &t.Path, &t.Filename, &t.Title, &t.Artist, &t.AlbumArtist, &t.Album, &t.Genre, &t.Year, &t.TrackNumber, &t.DiscNumber, &t.Duration, &t.Codec, &t.Bitrate, &t.SampleRate, &t.Channels, &t.Container, &t.Size, &t.Cover, &t.Favorite, &t.PlayCount, &t.PlaybackPosition)
	return t, e
}
func (s *Service) Get(ctx context.Context, id int64) (Track, error) {
	return scan(s.db.QueryRowContext(ctx, "SELECT "+cols+" FROM tracks WHERE id=?", id))
}
func (s *Service) List(ctx context.Context, q, artist, album string) ([]Track, error) {
	where := []string{"1=1"}
	args := []any{}
	if q != "" {
		where = append(where, "(title LIKE ? OR artist LIKE ? OR album LIKE ? OR genre LIKE ?)")
		x := "%" + q + "%"
		args = append(args, x, x, x, x)
	}
	if artist != "" {
		where = append(where, "artist=?")
		args = append(args, artist)
	}
	if album != "" {
		where = append(where, "album=?")
		args = append(args, album)
	}
	rows, e := s.db.QueryContext(ctx, "SELECT "+cols+" FROM tracks WHERE "+strings.Join(where, " AND ")+" ORDER BY album_artist COLLATE NOCASE,album COLLATE NOCASE,disc_number,track_number,title COLLATE NOCASE", args...)
	if e != nil {
		return nil, e
	}
	defer rows.Close()
	var out []Track
	for rows.Next() {
		t, e := scan(rows)
		if e != nil {
			return nil, e
		}
		out = append(out, t)
	}
	return out, rows.Err()
}
func (s *Service) Albums(ctx context.Context) ([]Album, error) {
	rows, e := s.db.QueryContext(ctx, `SELECT MIN(id),album,album_artist,MAX(cover),MAX(year),COUNT(*),SUM(duration) FROM tracks GROUP BY album,album_artist ORDER BY album_artist COLLATE NOCASE,album COLLATE NOCASE`)
	if e != nil {
		return nil, e
	}
	defer rows.Close()
	var out []Album
	for rows.Next() {
		var a Album
		if e = rows.Scan(&a.ID, &a.Title, &a.Artist, &a.Cover, &a.Year, &a.Tracks, &a.Duration); e != nil {
			return nil, e
		}
		out = append(out, a)
	}
	return out, rows.Err()
}
func (s *Service) Stats(ctx context.Context) (Stats, error) {
	var x Stats
	e := s.db.QueryRowContext(ctx, `SELECT COUNT(*),COUNT(DISTINCT artist),COUNT(DISTINCT album_artist||char(0)||album),COALESCE(SUM(duration),0) FROM tracks`).Scan(&x.Tracks, &x.Artists, &x.Albums, &x.Duration)
	return x, e
}
func (s *Service) Favorite(ctx context.Context, id int64) error {
	_, e := s.db.ExecContext(ctx, "UPDATE tracks SET favorite=1-favorite WHERE id=?", id)
	return e
}
func (s *Service) Played(ctx context.Context, id int64, pos float64, completed bool) error {
	inc := 0
	if completed {
		inc = 1
		pos = 0
	}
	_, e := s.db.ExecContext(ctx, "UPDATE tracks SET playback_position=?,play_count=play_count+?,last_played=? WHERE id=?", pos, inc, time.Now().Unix(), id)
	return e
}

func (s *Service) Audiobooks(ctx context.Context) ([]Audiobook, error) {
	rows, err := s.db.QueryContext(ctx, `SELECT MIN(id),book_path,book_title,MAX(cover),COUNT(*),SUM(duration),SUM(completed) FROM audiobook_chapters GROUP BY book_path,book_title ORDER BY book_title COLLATE NOCASE`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Audiobook
	for rows.Next() {
		var b Audiobook
		if err = rows.Scan(&b.ID, &b.Path, &b.Title, &b.Cover, &b.Chapters, &b.Duration, &b.Completed); err != nil {
			return nil, err
		}
		out = append(out, b)
	}
	return out, rows.Err()
}

func (s *Service) AudiobookChapters(ctx context.Context, bookPath string) ([]Chapter, error) {
	rows, err := s.db.QueryContext(ctx, `SELECT id,path,filename,book_path,book_title,title,duration,container,cover,playback_position,completed FROM audiobook_chapters WHERE book_path=?`, bookPath)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Chapter
	for rows.Next() {
		var c Chapter
		if err = rows.Scan(&c.ID, &c.Path, &c.Filename, &c.BookPath, &c.BookTitle, &c.Title, &c.Duration, &c.Container, &c.Cover, &c.PlaybackPosition, &c.Completed); err != nil {
			return nil, err
		}
		out = append(out, c)
	}
	sort.SliceStable(out, func(i, j int) bool { return naturalLess(out[i].Filename, out[j].Filename) })
	return out, rows.Err()
}

func (s *Service) AudiobookChapter(ctx context.Context, id int64) (Chapter, error) {
	var c Chapter
	err := s.db.QueryRowContext(ctx, `SELECT id,path,filename,book_path,book_title,title,duration,container,cover,playback_position,completed FROM audiobook_chapters WHERE id=?`, id).Scan(&c.ID, &c.Path, &c.Filename, &c.BookPath, &c.BookTitle, &c.Title, &c.Duration, &c.Container, &c.Cover, &c.PlaybackPosition, &c.Completed)
	return c, err
}

func (s *Service) AudiobookProgress(ctx context.Context, id int64, pos float64, completed bool) error {
	_, err := s.db.ExecContext(ctx, `UPDATE audiobook_chapters SET playback_position=?,completed=? WHERE id=?`, pos, completed, id)
	return err
}

func (s *Service) Playlists(ctx context.Context) ([]Playlist, error) {
	rows, err := s.db.QueryContext(ctx, `SELECT p.id,p.name,COUNT(t.track_id),COALESCE(SUM(m.duration),0) FROM playlists p LEFT JOIN playlist_tracks t ON t.playlist_id=p.id LEFT JOIN tracks m ON m.id=t.track_id GROUP BY p.id,p.name ORDER BY p.name COLLATE NOCASE`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Playlist
	for rows.Next() {
		var p Playlist
		if err = rows.Scan(&p.ID, &p.Name, &p.Tracks, &p.Duration); err != nil {
			return nil, err
		}
		out = append(out, p)
	}
	return out, rows.Err()
}
func (s *Service) PlaylistTracks(ctx context.Context, id int64) ([]Track, error) {
	rows, err := s.db.QueryContext(ctx, "SELECT "+cols+" FROM playlist_tracks p JOIN tracks t ON t.id=p.track_id WHERE p.playlist_id=? ORDER BY p.position", id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Track
	for rows.Next() {
		t, e := scan(rows)
		if e != nil {
			return nil, e
		}
		out = append(out, t)
	}
	return out, rows.Err()
}
func (s *Service) CreatePlaylist(ctx context.Context, name string) (int64, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return 0, errors.New("playlist name is required")
	}
	r, e := s.db.ExecContext(ctx, "INSERT INTO playlists(name,created_at) VALUES(?,?)", name, time.Now().Unix())
	if e != nil {
		return 0, e
	}
	return r.LastInsertId()
}
func (s *Service) AddToPlaylist(ctx context.Context, playlistID, trackID int64) error {
	_, e := s.db.ExecContext(ctx, `INSERT INTO playlist_tracks(playlist_id,track_id,position) VALUES(?,?,COALESCE((SELECT MAX(position)+1 FROM playlist_tracks WHERE playlist_id=?),0)) ON CONFLICT(playlist_id,track_id) DO NOTHING`, playlistID, trackID, playlistID)
	return e
}
func (s *Service) RemoveFromPlaylist(ctx context.Context, playlistID, trackID int64) error {
	_, e := s.db.ExecContext(ctx, "DELETE FROM playlist_tracks WHERE playlist_id=? AND track_id=?", playlistID, trackID)
	return e
}
func (s *Service) DeletePlaylist(ctx context.Context, id int64) error {
	_, e := s.db.ExecContext(ctx, "DELETE FROM playlists WHERE id=?", id)
	return e
}

var numberParts = regexp.MustCompile(`\d+|\D+`)

func naturalLess(a, b string) bool {
	aa, bb := numberParts.FindAllString(strings.ToLower(a), -1), numberParts.FindAllString(strings.ToLower(b), -1)
	for i := 0; i < len(aa) && i < len(bb); i++ {
		if aa[i] == bb[i] {
			continue
		}
		an, ae := strconv.Atoi(aa[i])
		bn, be := strconv.Atoi(bb[i])
		if ae == nil && be == nil {
			return an < bn
		}
		return aa[i] < bb[i]
	}
	return len(aa) < len(bb)
}