package web import ( "bufio" "database/sql" "fmt" "net/http" "path/filepath" "strings" "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) // handlePlaylistExport streams a playlist out as a standard M3U8 file: // an #EXTM3U header, an #EXTINF duration/title line per track, and the // track's absolute on-disk path (folder root + relative path) so a // re-import against the same library's folders resolves cleanly. func (h *Web) handlePlaylistExport(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) id := r.PathValue("id") playlist, err := database.GetPlaylist(h.DB, id) if err != nil { http.NotFound(w, r) return } if playlist.OwnerID != user.ID && !playlist.IsPublic { http.Error(w, "403 Forbidden", http.StatusForbidden) return } entries, err := database.ListPlaylistEntries(h.DB, id) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } folders, err := database.ListMusicFolders(h.DB) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } folderByID := make(map[string]*database.MusicFolder, len(folders)) for _, f := range folders { folderByID[f.ID] = f } filename := sanitizeFilename(playlist.Name) + ".m3u8" w.Header().Set("Content-Type", "audio/x-mpegurl; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) buf := bufio.NewWriter(w) defer buf.Flush() buf.WriteString("#EXTM3U\n") for _, e := range entries { song, err := database.GetSong(h.DB, e.SongID) if err != nil { continue } mf, err := database.GetMediaFile(h.DB, song.MediaFileID) if err != nil { continue } folder, ok := folderByID[mf.MusicFolderID] if !ok { continue } artist := "" if a := albumOf(h.DB, song); a != nil && a.PrimaryAlbumArtistID.Valid { if art, err := database.GetArtist(h.DB, a.PrimaryAlbumArtistID.String); err == nil { artist = art.Name } } title := song.Title if artist != "" { title = artist + " - " + title } fmt.Fprintf(buf, "#EXTINF:%d,%s\n", int(mf.DurationSeconds), title) buf.WriteString(filepath.Join(folder.Path, mf.RelativePath)) buf.WriteString("\n") } } // handlePlaylistImportForm renders the upload form. func (h *Web) handlePlaylistImportForm(w http.ResponseWriter, r *http.Request) { h.page(w, r, "playlist_import", "playlists", "Import Playlist", nil) } // playlistImportResult is the summary shown after an import: how many // M3U entries matched a track in the library versus were skipped. type playlistImportResult struct { Playlist *database.Playlist Matched int Skipped int } // handlePlaylistImport accepts an uploaded .m3u/.m3u8 file, resolves each // entry to a song already known to the library (matching by absolute // path against a scanned music folder's root, falling back to a // filename-only match within that folder), and creates a new playlist // from whatever matched. Unmatched lines are reported, not treated as an // error — a partial match is still useful to the user. func (h *Web) handlePlaylistImport(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if err := r.ParseMultipartForm(4 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, "no file uploaded", http.StatusBadRequest) return } defer file.Close() folders, err := database.ListMusicFolders(h.DB) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } name := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) if name == "" { name = "Imported Playlist" } playlist, err := database.CreatePlaylist(h.DB, user.ID, name, "", false) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } matched, skipped := 0, 0 scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } song := resolvePlaylistImportLine(h.DB, folders, line) if song == nil { skipped++ continue } if err := database.AppendPlaylistEntry(h.DB, playlist.ID, song.ID); err != nil { skipped++ continue } matched++ } h.page(w, r, "playlist_import_result", "playlists", "Import Result", playlistImportResult{ Playlist: playlist, Matched: matched, Skipped: skipped, }) } // resolvePlaylistImportLine matches one M3U entry (an absolute path, or a // path relative to a music folder root) to a known song. func resolvePlaylistImportLine(db *sql.DB, folders []*database.MusicFolder, line string) *database.Song { for _, folder := range folders { rel := line if strings.HasPrefix(line, folder.Path) { rel = strings.TrimPrefix(line, folder.Path) rel = strings.TrimPrefix(rel, string(filepath.Separator)) } else if filepath.IsAbs(line) { continue // absolute path under a different folder root: not a match here } mf, err := database.FindMediaFileByPath(db, folder.ID, rel) if err != nil { continue } song, err := database.GetSongByMediaFileID(db, mf.ID) if err != nil { continue } return song } return nil } func sanitizeFilename(name string) string { name = strings.Map(func(r rune) rune { switch r { case '/', '\\', ':', '*', '?', '"', '<', '>', '|': return '_' } return r }, name) name = strings.TrimSpace(name) if name == "" { return "playlist" } return name }