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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ dist/
|
||||
build/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
data/
|
||||
server.exe
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
export function PlayerBar() {
|
||||
const currentTrack = usePlayerStore((state) => state.currentTrack)
|
||||
const token = useSessionStore((state) => state.token)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || !currentTrack || !token) {
|
||||
return
|
||||
}
|
||||
|
||||
audioRef.current.src = `${apiBase}/api/stream/${currentTrack.id}?token=${token}`
|
||||
void audioRef.current.play().catch(() => {})
|
||||
}, [apiBase, currentTrack, token])
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded-[28px] border border-line bg-slate-950/70 p-4 backdrop-blur md:grid-cols-[1.3fr_auto_1fr] md:items-center">
|
||||
<audio ref={audioRef} preload="metadata" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">Now playing</div>
|
||||
<div className="mt-1 text-lg font-semibold">{currentTrack?.title ?? 'Nothing queued yet'}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{currentTrack ? `${currentTrack.artistName} • ${currentTrack.albumTitle}` : 'Wire this to /api/stream next'}
|
||||
{currentTrack ? `${currentTrack.artistName} • ${currentTrack.albumTitle}` : 'Pick a track from the library to start testing playback'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,4 +65,3 @@ function ControlButton({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/benya/temporserv/internal/config"
|
||||
"github.com/benya/temporserv/internal/db"
|
||||
"github.com/benya/temporserv/internal/httpapi"
|
||||
"github.com/benya/temporserv/internal/scanner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -32,7 +33,16 @@ func main() {
|
||||
log.Fatalf("database seed failed: %v", err)
|
||||
}
|
||||
|
||||
handler := httpapi.NewRouter(cfg, database)
|
||||
scanService := scanner.NewService(database, cfg.MediaRoot)
|
||||
if scanService.HasMediaFiles() {
|
||||
if result, err := scanService.Scan(ctx); err != nil {
|
||||
log.Printf("startup scan failed: %v", err)
|
||||
} else {
|
||||
log.Printf("startup scan completed: artists=%d albums=%d tracks=%d", result.Artists, result.Albums, result.Tracks)
|
||||
}
|
||||
}
|
||||
|
||||
handler := httpapi.NewRouter(cfg, database, scanService)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Address(),
|
||||
|
||||
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module github.com/benya/temporserv
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
golang.org/x/crypto v0.43.0
|
||||
modernc.org/sqlite v1.39.1
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
329
internal/scanner/service.go
Normal 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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user