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.
HTTPS
https://zanvex.de/git/miniaudiolib.gitinternal/musiclib/library.go
Zum Verzeichnispackage 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)
}