feat: import library from media root and stream tracks

Add a filesystem scanner that ingests supported audio files from MEDIA_ROOT into SQLite using embedded tags with filename fallbacks. Wire startup scanning, manual rescan, and authenticated audio streaming into the backend, then connect the web player to the real stream endpoint.
This commit is contained in:
2026-04-02 22:29:04 +03:00
parent e6a8d9411e
commit 46c2c3fb28
10 changed files with 468 additions and 5 deletions

View File

@@ -88,6 +88,10 @@ func (s *Service) Login(ctx context.Context, username, password string) (Session
func (s *Service) CurrentUser(ctx context.Context, authorizationHeader string) (User, error) {
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
return s.CurrentUserByToken(ctx, token)
}
func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrUnauthorized
}

View File

@@ -4,6 +4,9 @@ import (
"context"
"database/sql"
"fmt"
"io/fs"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -16,7 +19,7 @@ func Seed(ctx context.Context, database *sql.DB, cfg config.Config) error {
return err
}
if cfg.AppEnv == "development" {
if cfg.AppEnv == "development" && !hasMediaFiles(cfg.MediaRoot) {
if err := seedLibrary(ctx, database); err != nil {
return err
}
@@ -25,6 +28,24 @@ func Seed(ctx context.Context, database *sql.DB, cfg config.Config) error {
return nil
}
func hasMediaFiles(root string) bool {
found := false
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
switch strings.ToLower(filepath.Ext(path)) {
case ".aac", ".flac", ".m4a", ".mp3", ".ogg", ".oga", ".opus", ".wav", ".wma":
found = true
return fs.SkipAll
}
return nil
})
return found
}
func seedAdmin(ctx context.Context, database *sql.DB, cfg config.Config) error {
var count int
if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
@@ -13,18 +14,21 @@ import (
"github.com/benya/temporserv/internal/auth"
"github.com/benya/temporserv/internal/config"
"github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/scanner"
"github.com/benya/temporserv/internal/subsonic"
)
type app struct {
auth *auth.Service
library *library.Service
scanner *scanner.Service
}
func NewRouter(cfg config.Config, database *sql.DB) http.Handler {
func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler {
application := app{
auth: auth.NewService(database),
library: library.NewService(database),
scanner: scanService,
}
r := chi.NewRouter()
@@ -48,7 +52,10 @@ func NewRouter(cfg config.Config, database *sql.DB) http.Handler {
private.Get("/me", application.me)
private.Get("/home", application.home)
private.Get("/tracks", application.tracks)
private.Post("/admin/scan", application.scanLibrary)
})
api.Get("/stream/{id}", application.streamTrack)
})
r.Route("/rest", func(rest chi.Router) {
@@ -133,6 +140,49 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
}
func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
result, err := a.scanner.Scan(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (a app) streamTrack(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
}
if _, err := a.auth.CurrentUserByToken(r.Context(), token); err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
track, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
return
}
file, err := os.Open(track.FilePath)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio file not available"})
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat file"})
return
}
w.Header().Set("Content-Type", track.ContentType)
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -160,3 +160,34 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
return tracks, rows.Err()
}
func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
var track Track
err := s.db.QueryRowContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE t.id = ?`,
id,
).Scan(
&track.ID,
&track.AlbumID,
&track.ArtistID,
&track.Title,
&track.ArtistName,
&track.AlbumTitle,
&track.TrackNumber,
&track.DurationSecs,
&track.FilePath,
&track.ContentType,
)
if err != nil {
return Track{}, fmt.Errorf("query track by id: %w", err)
}
return track, nil
}

329
internal/scanner/service.go Normal file
View File

@@ -0,0 +1,329 @@
package scanner
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/dhowden/tag"
)
var supportedExtensions = []string{
".aac",
".flac",
".m4a",
".mp3",
".ogg",
".oga",
".opus",
".wav",
".wma",
}
type Result struct {
Artists int `json:"artists"`
Albums int `json:"albums"`
Tracks int `json:"tracks"`
}
type Service struct {
db *sql.DB
mediaRoot string
}
type scannedArtist struct {
ID string
Name string
}
type scannedAlbum struct {
ID string
ArtistID string
Title string
Year int
Genre string
}
type scannedTrack struct {
ID string
AlbumID string
ArtistID string
Title string
TrackNumber int
DiscNumber int
DurationSecs int
FilePath string
ContentType string
}
func NewService(db *sql.DB, mediaRoot string) *Service {
return &Service{db: db, mediaRoot: mediaRoot}
}
func (s *Service) HasMediaFiles() bool {
found := false
_ = filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if isSupportedAudio(path) {
found = true
return fs.SkipAll
}
return nil
})
return found
}
func (s *Service) Scan(ctx context.Context) (Result, error) {
if s.mediaRoot == "" {
return Result{}, fmt.Errorf("media root is empty")
}
if _, err := os.Stat(s.mediaRoot); err != nil {
return Result{}, fmt.Errorf("media root unavailable: %w", err)
}
artists := map[string]scannedArtist{}
albums := map[string]scannedAlbum{}
var tracks []scannedTrack
err := filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !isSupportedAudio(path) {
return nil
}
item, err := s.scanFile(path)
if err != nil {
return nil
}
artists[item.artist.ID] = item.artist
albums[item.album.ID] = item.album
tracks = append(tracks, item.track)
return nil
})
if err != nil {
return Result{}, fmt.Errorf("walk media root: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Result{}, fmt.Errorf("begin scan transaction: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("clear tracks: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("clear albums: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("clear artists: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
artistKeys := make([]string, 0, len(artists))
for key := range artists {
artistKeys = append(artistKeys, key)
}
slices.Sort(artistKeys)
for _, key := range artistKeys {
artist := artists[key]
if _, err := tx.ExecContext(
ctx,
`INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
artist.ID,
artist.Name,
artist.Name,
"",
now,
now,
); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("insert artist: %w", err)
}
}
albumKeys := make([]string, 0, len(albums))
for key := range albums {
albumKeys = append(albumKeys, key)
}
slices.Sort(albumKeys)
for _, key := range albumKeys {
album := albums[key]
if _, err := tx.ExecContext(
ctx,
`INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
album.ID,
album.ArtistID,
album.Title,
album.Title,
album.Year,
album.Genre,
"",
now,
now,
); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("insert album: %w", err)
}
}
for _, track := range tracks {
if _, err := tx.ExecContext(
ctx,
`INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
track.ID,
track.AlbumID,
track.ArtistID,
track.Title,
track.TrackNumber,
track.DiscNumber,
track.DurationSecs,
track.FilePath,
track.ContentType,
now,
now,
); err != nil {
_ = tx.Rollback()
return Result{}, fmt.Errorf("insert track: %w", err)
}
}
if err := tx.Commit(); err != nil {
return Result{}, fmt.Errorf("commit scan transaction: %w", err)
}
return Result{
Artists: len(artists),
Albums: len(albums),
Tracks: len(tracks),
}, nil
}
type scannedItem struct {
artist scannedArtist
album scannedAlbum
track scannedTrack
}
func (s *Service) scanFile(path string) (scannedItem, error) {
file, err := os.Open(path)
if err != nil {
return scannedItem{}, err
}
defer file.Close()
metadata, err := tag.ReadFrom(file)
title := strings.TrimSpace(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
artistName := "Unknown Artist"
albumTitle := "Unknown Album"
genre := ""
year := 0
trackNumber := 0
discNumber := 0
if err == nil {
if value := strings.TrimSpace(metadata.Title()); value != "" {
title = value
}
if value := strings.TrimSpace(metadata.Artist()); value != "" {
artistName = value
}
if value := strings.TrimSpace(metadata.Album()); value != "" {
albumTitle = value
}
if value := strings.TrimSpace(metadata.Genre()); value != "" {
genre = value
}
year = metadata.Year()
trackNumber, _ = metadata.Track()
discNumber, _ = metadata.Disc()
}
artistID := hashID("artist", artistName)
albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year))
trackID := hashID("track", path)
return scannedItem{
artist: scannedArtist{
ID: artistID,
Name: artistName,
},
album: scannedAlbum{
ID: albumID,
ArtistID: artistID,
Title: albumTitle,
Year: year,
Genre: genre,
},
track: scannedTrack{
ID: trackID,
AlbumID: albumID,
ArtistID: artistID,
Title: title,
TrackNumber: trackNumber,
DiscNumber: discNumber,
DurationSecs: 0,
FilePath: path,
ContentType: detectContentType(path),
},
}, nil
}
func isSupportedAudio(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, supported := range supportedExtensions {
if ext == supported {
return true
}
}
return false
}
func detectContentType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".m4a":
return "audio/mp4"
case ".ogg", ".oga":
return "audio/ogg"
case ".opus":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
case ".wma":
return "audio/x-ms-wma"
default:
return "application/octet-stream"
}
}
func hashID(parts ...string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "::")))
return hex.EncodeToString(sum[:])
}