fix(13-02): resolve phantom playlist tracks using M3U8 paths after scan
- Add ScanHooks callback struct to library package (follows RemovalHooks pattern) - Move phantom resolution from library to playlist service via hook - New ResolvePhantomTracksAfterScan reads M3U8 files and resolves paths against current audio_files using multi-root resolution - Handles pre-existing phantoms (match by M3U8 position) and new ones (match by phantom_file_path) - Delete old resolvePhantomTracks method that required phantom_file_path - Wire ScanHooks in app.go OnStartup
This commit is contained in:
@@ -171,6 +171,12 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
PostScan: yj.playlist.RestoreAllPlaylists,
|
PostScan: yj.playlist.RestoreAllPlaylists,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wire scan hooks so the playlist service can resolve
|
||||||
|
// phantom tracks after each library scan completes.
|
||||||
|
yj.library.SetScanHooks(library.ScanHooks{
|
||||||
|
ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan,
|
||||||
|
})
|
||||||
|
|
||||||
// Wire removal hooks so the library can stop playback and
|
// Wire removal hooks so the library can stop playback and
|
||||||
// compact the queue during library removal without depending
|
// compact the queue during library removal without depending
|
||||||
// on the player or queue packages directly.
|
// on the player or queue packages directly.
|
||||||
|
|||||||
+27
-53
@@ -72,6 +72,15 @@ type RescanHooks struct {
|
|||||||
PostScan func()
|
PostScan func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanHooks contains callbacks invoked after a library scan
|
||||||
|
// completes. The app layer wires these so the library package
|
||||||
|
// does not depend on the playlist package directly.
|
||||||
|
type ScanHooks struct {
|
||||||
|
// ResolvePhantoms re-links phantom playlist tracks whose
|
||||||
|
// files now exist in the library after scanning.
|
||||||
|
ResolvePhantoms func()
|
||||||
|
}
|
||||||
|
|
||||||
// Library manages scanning and querying the music collection.
|
// Library manages scanning and querying the music collection.
|
||||||
type Library struct {
|
type Library struct {
|
||||||
// mu protects ctx, conf, and rescanHooks from concurrent
|
// mu protects ctx, conf, and rescanHooks from concurrent
|
||||||
@@ -97,6 +106,10 @@ type Library struct {
|
|||||||
// removalHooks holds callbacks for cross-cutting concerns during
|
// removalHooks holds callbacks for cross-cutting concerns during
|
||||||
// library removal (e.g. stopping playback, compacting queue).
|
// library removal (e.g. stopping playback, compacting queue).
|
||||||
removalHooks RemovalHooks
|
removalHooks RemovalHooks
|
||||||
|
|
||||||
|
// scanHooks holds callbacks for post-scan processing
|
||||||
|
// (e.g. resolving phantom playlist tracks).
|
||||||
|
scanHooks ScanHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRescanHooks provides optional hooks for cross-cutting
|
// SetRescanHooks provides optional hooks for cross-cutting
|
||||||
@@ -108,6 +121,15 @@ func (l *Library) SetRescanHooks(h RescanHooks) {
|
|||||||
l.rescanHooks = h
|
l.rescanHooks = h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetScanHooks provides optional hooks for cross-cutting
|
||||||
|
// orchestration after each library scan.
|
||||||
|
func (l *Library) SetScanHooks(h ScanHooks) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.scanHooks = h
|
||||||
|
}
|
||||||
|
|
||||||
// NewLibrary creates a new library with the given configuration.
|
// NewLibrary creates a new library with the given configuration.
|
||||||
// A nil config is permitted; scan paths come from the database
|
// A nil config is permitted; scan paths come from the database
|
||||||
// rather than from the config's DirectoryPath.
|
// rather than from the config's DirectoryPath.
|
||||||
@@ -653,12 +675,11 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 6: resolve phantom playlist tracks ---
|
// --- Phase 6: resolve phantom playlist tracks ---
|
||||||
// Phantom tracks (audio_file_id IS NULL) that have a stored
|
// Delegated to the playlist service via ScanHooks so that
|
||||||
// phantom_file_path matching a newly-scanned audio file are
|
// M3U8-based path resolution can handle both pre-existing
|
||||||
// automatically re-linked. This handles the case where a
|
// phantoms (no phantom_file_path) and new ones.
|
||||||
// library directory is removed and later re-added.
|
if !cancelled && l.scanHooks.ResolvePhantoms != nil {
|
||||||
if !cancelled {
|
l.scanHooks.ResolvePhantoms()
|
||||||
l.resolvePhantomTracks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 7: post-scan variant generation ---
|
// --- Phase 7: post-scan variant generation ---
|
||||||
@@ -717,53 +738,6 @@ func (l *Library) scanInternal(
|
|||||||
return metrics
|
return metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePhantomTracks re-links phantom playlist_tracks entries
|
|
||||||
// whose phantom_file_path now matches an audio_files row. This
|
|
||||||
// runs after every successful scan so that re-adding a previously
|
|
||||||
// removed library automatically restores playlist references.
|
|
||||||
func (l *Library) resolvePhantomTracks() {
|
|
||||||
// SAFETY: Hand-crafted UPDATE for phantom track resolution.
|
|
||||||
// Matches phantom playlist_tracks (audio_file_id IS NULL,
|
|
||||||
// phantom_file_path IS NOT NULL) against audio_files by
|
|
||||||
// file_path. Clears phantom metadata on resolved rows.
|
|
||||||
// No user input — all values come from the database.
|
|
||||||
result, err := l.db.ExecContext(`
|
|
||||||
UPDATE playlist_tracks SET
|
|
||||||
audio_file_id = (
|
|
||||||
SELECT af.id FROM audio_files af
|
|
||||||
WHERE af.file_path = playlist_tracks.phantom_file_path
|
|
||||||
),
|
|
||||||
phantom_title = NULL,
|
|
||||||
phantom_artist = NULL,
|
|
||||||
phantom_album = NULL,
|
|
||||||
phantom_duration_ms = NULL,
|
|
||||||
phantom_genre = NULL,
|
|
||||||
phantom_cover_art_path = NULL,
|
|
||||||
phantom_file_path = NULL
|
|
||||||
WHERE audio_file_id IS NULL
|
|
||||||
AND phantom_file_path IS NOT NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM audio_files af
|
|
||||||
WHERE af.file_path = playlist_tracks.phantom_file_path
|
|
||||||
)`)
|
|
||||||
if err != nil {
|
|
||||||
l.logger.Warn(
|
|
||||||
"could not resolve phantom playlist tracks",
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved, _ := result.RowsAffected()
|
|
||||||
if resolved > 0 {
|
|
||||||
l.logger.Info(
|
|
||||||
"resolved phantom playlist tracks",
|
|
||||||
"count", resolved,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// progressInterval controls how often scan progress events are
|
// progressInterval controls how often scan progress events are
|
||||||
// emitted to the frontend.
|
// emitted to the frontend.
|
||||||
const progressInterval = 300 * time.Millisecond
|
const progressInterval = 300 * time.Millisecond
|
||||||
|
|||||||
@@ -1494,6 +1494,305 @@ func (s *Service) migrateExistingPlaylists() {
|
|||||||
// Phantom track resolution
|
// Phantom track resolution
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
|
// ResolvePhantomTracksAfterScan re-links phantom playlist tracks
|
||||||
|
// whose files now exist in the library. It iterates each playlist
|
||||||
|
// that has phantoms, reads its M3U8 file, resolves each entry
|
||||||
|
// against the current audio_files table using multi-root path
|
||||||
|
// resolution, and updates matching phantom playlist_tracks. This
|
||||||
|
// handles both pre-existing phantoms (created before migration 7,
|
||||||
|
// with NULL phantom_file_path) and new ones.
|
||||||
|
func (s *Service) ResolvePhantomTracksAfterScan() {
|
||||||
|
// 1. Get distinct playlist IDs that have phantom tracks.
|
||||||
|
// SAFETY: Hand-crafted SELECT for phantom playlist IDs.
|
||||||
|
// No user input — reads only system state.
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
`SELECT DISTINCT playlist_id
|
||||||
|
FROM playlist_tracks
|
||||||
|
WHERE audio_file_id IS NULL`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not query phantom playlists",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var phantomPlaylistIDs []int64
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not scan phantom playlist ID",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
phantomPlaylistIDs = append(phantomPlaylistIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not close phantom playlist rows",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(phantomPlaylistIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build audio file path→ID map for resolution.
|
||||||
|
// SAFETY: Hand-crafted SELECT for full audio file path map.
|
||||||
|
// No user input — reads only system state.
|
||||||
|
afRows, err := s.db.QueryContext(
|
||||||
|
`SELECT id, file_path FROM audio_files`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not query audio files for phantom resolution",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFileByPath := make(map[string]int64)
|
||||||
|
|
||||||
|
for afRows.Next() {
|
||||||
|
var id int64
|
||||||
|
|
||||||
|
var fp string
|
||||||
|
if err := afRows.Scan(&id, &fp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFileByPath[fp] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := afRows.Close(); err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not close audio file rows",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(audioFileByPath) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build knownPaths set for resolveM3UPath.
|
||||||
|
knownPaths := make(
|
||||||
|
map[string]struct{}, len(audioFileByPath),
|
||||||
|
)
|
||||||
|
for k := range audioFileByPath {
|
||||||
|
knownPaths[k] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryRoots := s.getAllLibraryRoots()
|
||||||
|
|
||||||
|
dir, err := s.playlistsDir()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not get playlists dir for phantom resolution",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalResolved int
|
||||||
|
|
||||||
|
// 4. For each playlist with phantoms, resolve via M3U8.
|
||||||
|
for _, playlistID := range phantomPlaylistIDs {
|
||||||
|
resolved := s.resolvePlaylistPhantoms(
|
||||||
|
playlistID, dir, libraryRoots,
|
||||||
|
knownPaths, audioFileByPath,
|
||||||
|
)
|
||||||
|
totalResolved += resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalResolved > 0 {
|
||||||
|
s.logger.Info(
|
||||||
|
"resolved phantom playlist tracks after scan",
|
||||||
|
"count", totalResolved,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.emitEvent(events.PlaylistTracksChanged, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// phantomTrackRow holds the minimal fields needed to match a
|
||||||
|
// phantom playlist_track against an M3U8 entry.
|
||||||
|
type phantomTrackRow struct {
|
||||||
|
id int64
|
||||||
|
position int64
|
||||||
|
phantomFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePlaylistPhantoms resolves phantom tracks for a single
|
||||||
|
// playlist by reading its M3U8 file and matching entries against
|
||||||
|
// the audio_files table. Returns the number of resolved tracks.
|
||||||
|
func (s *Service) resolvePlaylistPhantoms(
|
||||||
|
playlistID int64,
|
||||||
|
dir string,
|
||||||
|
libraryRoots []string,
|
||||||
|
knownPaths map[string]struct{},
|
||||||
|
audioFileByPath map[string]int64,
|
||||||
|
) int {
|
||||||
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
||||||
|
if err != nil || m3uPath == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parseM3U8(m3uPath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not parse M3U8 for phantom resolution",
|
||||||
|
"playlistId", playlistID,
|
||||||
|
"path", m3uPath,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load phantom tracks for this playlist.
|
||||||
|
// SAFETY: Hand-crafted SELECT for phantom tracks with
|
||||||
|
// position and phantom_file_path. No user input.
|
||||||
|
ptRows, err := s.db.QueryContext(
|
||||||
|
`SELECT id, position, COALESCE(phantom_file_path, '')
|
||||||
|
FROM playlist_tracks
|
||||||
|
WHERE playlist_id = ? AND audio_file_id IS NULL`,
|
||||||
|
playlistID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not query phantom tracks",
|
||||||
|
"playlistId", playlistID,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var phantoms []phantomTrackRow
|
||||||
|
|
||||||
|
for ptRows.Next() {
|
||||||
|
var pt phantomTrackRow
|
||||||
|
if err := ptRows.Scan(
|
||||||
|
&pt.id, &pt.position, &pt.phantomFilePath,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
phantoms = append(phantoms, pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ptRows.Close(); err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not close phantom track rows",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(phantoms) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a set of already-resolved phantom IDs to avoid
|
||||||
|
// double-matching.
|
||||||
|
resolvedIDs := make(map[int64]struct{})
|
||||||
|
|
||||||
|
var resolved int
|
||||||
|
|
||||||
|
// For each M3U8 entry, resolve its path and try to match
|
||||||
|
// a phantom track.
|
||||||
|
for i, entry := range parsed.Entries {
|
||||||
|
absPath := resolveM3UPath(
|
||||||
|
entry.RelativePath, libraryRoots, knownPaths,
|
||||||
|
)
|
||||||
|
|
||||||
|
audioFileID, exists := audioFileByPath[absPath]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the phantom that corresponds to this entry.
|
||||||
|
// Priority 1: match by phantom_file_path (exact).
|
||||||
|
// Priority 2: match by position (M3U8 index).
|
||||||
|
matchIdx := -1
|
||||||
|
|
||||||
|
for j, pt := range phantoms {
|
||||||
|
if _, done := resolvedIDs[pt.id]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if pt.phantomFilePath != "" &&
|
||||||
|
pt.phantomFilePath == absPath {
|
||||||
|
matchIdx = j
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchIdx == -1 {
|
||||||
|
for j, pt := range phantoms {
|
||||||
|
if _, done := resolvedIDs[pt.id]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if pt.position == int64(i) {
|
||||||
|
matchIdx = j
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchIdx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pt := phantoms[matchIdx]
|
||||||
|
|
||||||
|
// SAFETY: Hand-crafted UPDATE to resolve a phantom
|
||||||
|
// playlist track. Sets audio_file_id and clears all
|
||||||
|
// phantom metadata columns. Parameterized by ID.
|
||||||
|
if _, err := s.db.ExecContext(
|
||||||
|
`UPDATE playlist_tracks SET
|
||||||
|
audio_file_id = ?,
|
||||||
|
phantom_title = NULL,
|
||||||
|
phantom_artist = NULL,
|
||||||
|
phantom_album = NULL,
|
||||||
|
phantom_duration_ms = NULL,
|
||||||
|
phantom_genre = NULL,
|
||||||
|
phantom_cover_art_path = NULL,
|
||||||
|
phantom_file_path = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
audioFileID, pt.id,
|
||||||
|
); err != nil {
|
||||||
|
s.logger.Warn(
|
||||||
|
"could not resolve phantom track",
|
||||||
|
"playlistTrackId", pt.id,
|
||||||
|
"audioFileId", audioFileID,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedIDs[pt.id] = struct{}{}
|
||||||
|
resolved++
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
// FindPhantomMatches searches the library for matches for the
|
// FindPhantomMatches searches the library for matches for the
|
||||||
// given phantom file paths. High-confidence matches are returned
|
// given phantom file paths. High-confidence matches are returned
|
||||||
// as auto-matched pairs; the rest remain in the unmatched list.
|
// as auto-matched pairs; the rest remain in the unmatched list.
|
||||||
|
|||||||
+2
@@ -76,4 +76,6 @@ export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
|
|||||||
|
|
||||||
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
||||||
|
|
||||||
|
export function SetScanHooks(arg1:library.ScanHooks):Promise<void>;
|
||||||
|
|
||||||
export function SoftScanAllLibraries():Promise<void>;
|
export function SoftScanAllLibraries():Promise<void>;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export function SetRescanHooks(arg1) {
|
|||||||
return window['go']['library']['Library']['SetRescanHooks'](arg1);
|
return window['go']['library']['Library']['SetRescanHooks'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetScanHooks(arg1) {
|
||||||
|
return window['go']['library']['Library']['SetScanHooks'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SoftScanAllLibraries() {
|
export function SoftScanAllLibraries() {
|
||||||
return window['go']['library']['Library']['SoftScanAllLibraries']();
|
return window['go']['library']['Library']['SoftScanAllLibraries']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ export namespace library {
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ScanHooks {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ScanHooks(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScanWarning {
|
export class ScanWarning {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
|||||||
+2
@@ -45,6 +45,8 @@ export function RenamePlaylist(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function ResolvePhantomTracks(arg1:number,arg2:Record<string, string>):Promise<void>;
|
export function ResolvePhantomTracks(arg1:number,arg2:Record<string, string>):Promise<void>;
|
||||||
|
|
||||||
|
export function ResolvePhantomTracksAfterScan():Promise<void>;
|
||||||
|
|
||||||
export function RestoreAllPlaylists():Promise<void>;
|
export function RestoreAllPlaylists():Promise<void>;
|
||||||
|
|
||||||
export function SearchLibrary(arg1:string):Promise<Array<playlist.CandidateTrack>>;
|
export function SearchLibrary(arg1:string):Promise<Array<playlist.CandidateTrack>>;
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export function ResolvePhantomTracks(arg1, arg2) {
|
|||||||
return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2);
|
return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResolvePhantomTracksAfterScan() {
|
||||||
|
return window['go']['playlist']['Service']['ResolvePhantomTracksAfterScan']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RestoreAllPlaylists() {
|
export function RestoreAllPlaylists() {
|
||||||
return window['go']['playlist']['Service']['RestoreAllPlaylists']();
|
return window['go']['playlist']['Service']['RestoreAllPlaylists']();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user