Initial commit

This commit is contained in:
Storm Dragon
2025-12-15 04:09:55 -05:00
commit 555ca0bba9
26 changed files with 4532 additions and 0 deletions

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""NaviPy - Accessible Navidrome Client"""

View File

@@ -0,0 +1 @@
"""Accessibility widgets module"""

View File

@@ -0,0 +1,76 @@
"""
Accessible combo box with enhanced keyboard navigation
Based on the implementation from Bifrost/Doom Launcher
"""
from PySide6.QtWidgets import QComboBox
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeyEvent
class AccessibleComboBox(QComboBox):
"""ComboBox with enhanced keyboard navigation for accessibility"""
def __init__(self, parent=None):
super().__init__(parent)
self.setEditable(True)
self.lineEdit().setReadOnly(True)
self.pageStep = 5 # Number of items to jump for page up/down
def keyPressEvent(self, event: QKeyEvent):
"""Handle keyboard navigation with accessibility enhancements"""
currentIndex = self.currentIndex()
itemCount = self.count()
if event.key() == Qt.Key_PageUp:
# Jump up by pageStep items
newIndex = max(0, currentIndex - self.pageStep)
self.setCurrentIndex(newIndex)
elif event.key() == Qt.Key_PageDown:
# Jump down by pageStep items
newIndex = min(itemCount - 1, currentIndex + self.pageStep)
self.setCurrentIndex(newIndex)
elif event.key() == Qt.Key_Home:
# Go to first item
self.setCurrentIndex(0)
# Force update and focus events
self.setFocus()
self.currentIndexChanged.emit(0)
self.activated.emit(0)
elif event.key() == Qt.Key_End:
# Go to last item
lastIndex = itemCount - 1
self.setCurrentIndex(lastIndex)
# Force update and focus events
self.setFocus()
self.currentIndexChanged.emit(lastIndex)
self.activated.emit(lastIndex)
else:
# Use default behavior for other keys
super().keyPressEvent(event)
def wheelEvent(self, event):
"""Handle mouse wheel events with moderation"""
# Only change selection if the widget has focus
if self.hasFocus():
super().wheelEvent(event)
else:
# Ignore wheel events when not focused to prevent accidental changes
event.ignore()
def setAccessibleName(self, name: str):
"""Set accessible name and ensure it's properly applied"""
super().setAccessibleName(name)
# Also set it on the line edit for better screen reader support
if self.lineEdit():
self.lineEdit().setAccessibleName(name)
def setAccessibleDescription(self, description: str):
"""Set accessible description"""
super().setAccessibleDescription(description)
if self.lineEdit():
self.lineEdit().setAccessibleDescription(description)

View File

@@ -0,0 +1,340 @@
"""
Accessible tree widget for hierarchical navigation
Based on the implementation from Bifrost
"""
import re
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QKeyEvent
class AccessibleTreeWidget(QTreeWidget):
"""Tree widget with enhanced accessibility for hierarchical content"""
itemStateChanged = Signal(QTreeWidgetItem, str) # item, state_description
def __init__(self, parent=None):
super().__init__(parent)
self.pageStep = 5 # Number of items to jump for page up/down
self.setupAccessibility()
def setupAccessibility(self):
"""Set up accessibility features"""
self.setFocusPolicy(Qt.StrongFocus)
self.itemExpanded.connect(self.onItemExpanded)
self.itemCollapsed.connect(self.onItemCollapsed)
def keyPressEvent(self, event: QKeyEvent):
"""Handle keyboard navigation with accessibility enhancements"""
key = event.key()
current = self.currentItem()
# Handle context menu key (Menu key or Shift+F10)
if key == Qt.Key_Menu or (key == Qt.Key_F10 and event.modifiers() & Qt.ShiftModifier):
if current:
# Get the position of the current item
rect = self.visualItemRect(current)
center = rect.center()
# Emit context menu signal
self.customContextMenuRequested.emit(center)
return
if not current:
super().keyPressEvent(event)
return
# Handle Enter key for item activation
if key == Qt.Key_Return or key == Qt.Key_Enter:
self.itemActivated.emit(current, 0)
return
# Handle copy to clipboard shortcut
if key == Qt.Key_C and event.modifiers() & Qt.ControlModifier:
if hasattr(self.parent(), 'copyToClipboard'):
self.parent().copyToClipboard()
return
# Check for Shift modifier
hasShift = event.modifiers() & Qt.ShiftModifier
if key == Qt.Key_Right:
# Right Arrow: Expand node or move to first child
if current.childCount() > 0:
if not current.isExpanded():
self.expandItem(current)
self.updateChildAccessibility(current, True)
return
# If already expanded and no shift, move to first child
elif not hasShift:
firstChild = current.child(0)
if firstChild and self.isItemNavigable(firstChild):
self.setCurrentItem(firstChild)
self.scrollToItem(firstChild)
return
elif key == Qt.Key_Left:
if hasShift:
# Shift+Left: Navigate to root parent
rootParent = self.getRootParent(current)
if rootParent and rootParent != current:
self.setCurrentItem(rootParent)
self.scrollToItem(rootParent)
return
else:
# Plain Left: Collapse current item if expanded, otherwise move to parent
if current.childCount() > 0 and current.isExpanded():
self.collapseItem(current)
self.updateChildAccessibility(current, False)
self.repaint()
self.update()
return
elif current.parent():
parentItem = current.parent()
self.setCurrentItem(parentItem)
self.scrollToItem(parentItem)
return
elif key == Qt.Key_Down:
# Move to next visible item
nextItem = self.getNextVisibleItem(current)
if nextItem:
self.setCurrentItem(nextItem)
self.scrollToItem(nextItem)
return
elif key == Qt.Key_Up:
# Move to previous visible item
prevItem = self.getPreviousVisibleItem(current)
if prevItem:
self.setCurrentItem(prevItem)
self.scrollToItem(prevItem)
return
elif key == Qt.Key_PageDown:
# Jump down by pageStep items
target = current
for _ in range(self.pageStep):
nextItem = self.getNextVisibleItem(target)
if nextItem:
target = nextItem
else:
break
self.setCurrentItem(target)
self.scrollToItem(target)
return
elif key == Qt.Key_PageUp:
# Jump up by pageStep items
target = current
for _ in range(self.pageStep):
prevItem = self.getPreviousVisibleItem(target)
if prevItem:
target = prevItem
else:
break
self.setCurrentItem(target)
self.scrollToItem(target)
return
elif key == Qt.Key_Home:
# Go to first item
firstItem = self.topLevelItem(0)
if firstItem:
self.setCurrentItem(firstItem)
self.scrollToItem(firstItem)
return
elif key == Qt.Key_End:
# Go to last visible item
lastItem = self.getLastVisibleItem()
if lastItem:
self.setCurrentItem(lastItem)
self.scrollToItem(lastItem)
return
# Only fall back to default behavior if we didn't handle the key
if key not in [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down,
Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End]:
super().keyPressEvent(event)
def getNextVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the next visible item in the tree"""
if not item:
return None
# If item has children, check if first child is navigable
if item.childCount() > 0:
firstChild = item.child(0)
if firstChild and self.isItemNavigable(firstChild):
return firstChild
# Otherwise, find next sibling or ancestor's sibling
current = item
while current:
parent = current.parent()
if parent:
# Find next sibling
index = parent.indexOfChild(current)
if index + 1 < parent.childCount():
nextSibling = parent.child(index + 1)
if nextSibling and self.isItemNavigable(nextSibling):
return nextSibling
# No more siblings, go up to parent
current = parent
else:
# Top-level item, find next top-level item
index = self.indexOfTopLevelItem(current)
if index + 1 < self.topLevelItemCount():
nextItem = self.topLevelItem(index + 1)
if nextItem and self.isItemNavigable(nextItem):
return nextItem
# No more items
return None
return None
def getPreviousVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the previous visible item in the tree"""
if not item:
return None
parent = item.parent()
if parent:
# Find previous sibling
index = parent.indexOfChild(item)
if index > 0:
# Get previous sibling and its last visible descendant
prevSibling = parent.child(index - 1)
if prevSibling and self.isItemNavigable(prevSibling):
return self.getLastVisibleDescendant(prevSibling)
else:
# No previous sibling, go to parent
if self.isItemNavigable(parent):
return parent
else:
# Top-level item
index = self.indexOfTopLevelItem(item)
if index > 0:
# Get previous top-level item and its last visible descendant
prevItem = self.topLevelItem(index - 1)
if prevItem and self.isItemNavigable(prevItem):
return self.getLastVisibleDescendant(prevItem)
return None
def getLastVisibleDescendant(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the last visible descendant of an item"""
if not item or not self.isItemNavigable(item):
return None
# Check if any children are navigable
if item.childCount() > 0:
# Get the last child and check if it's navigable
lastChild = item.child(item.childCount() - 1)
if lastChild and self.isItemNavigable(lastChild):
result = self.getLastVisibleDescendant(lastChild)
return result if result else item
return item
def getLastVisibleItem(self) -> QTreeWidgetItem:
"""Get the last visible item in the tree"""
if self.topLevelItemCount() == 0:
return None
lastTopLevel = self.topLevelItem(self.topLevelItemCount() - 1)
return self.getLastVisibleDescendant(lastTopLevel)
def onItemExpanded(self, item: QTreeWidgetItem):
"""Handle item expansion"""
self.announceItemState(item, "expanded")
# Make child items accessible
self.updateChildAccessibility(item, True)
def onItemCollapsed(self, item: QTreeWidgetItem):
"""Handle item collapse"""
self.announceItemState(item, "collapsed")
# Hide child items from screen readers
self.updateChildAccessibility(item, False)
def updateChildAccessibility(self, item: QTreeWidgetItem, visible: bool):
"""Update accessibility properties of child items"""
for i in range(item.childCount()):
child = item.child(i)
if visible:
# Make child accessible and ensure proper selection
child.setFlags(child.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
child.setData(0, Qt.AccessibleDescriptionRole, "") # Clear hidden marker
# Recursively handle nested children
self.updateChildAccessibility(child, child.isExpanded())
else:
# Hide from screen readers and navigation
child.setData(0, Qt.AccessibleDescriptionRole, "hidden")
# Remove selectable flag - collapsed items should not be navigable
child.setFlags((child.flags() | Qt.ItemIsEnabled) & ~Qt.ItemIsSelectable)
# Hide all nested children too
self.updateChildAccessibility(child, False)
def announceItemState(self, item: QTreeWidgetItem, state: str):
"""Announce item state change for screen readers"""
# Update accessible text to include state
if item.childCount() > 0:
originalText = item.text(0)
childCount = item.childCount()
# Remove any existing state information to prevent duplication
cleanText = re.sub(r' \(\d+ items?, (?:collapsed|expanded)\)$', '', originalText)
accessibleText = f"{cleanText} ({childCount} items, {state})"
item.setData(0, Qt.AccessibleTextRole, accessibleText)
# Emit signal for potential sound feedback
self.itemStateChanged.emit(item, state)
def getItemDescription(self, item: QTreeWidgetItem) -> str:
"""Get a full description of an item for screen readers"""
if not item:
return "No item selected"
text = item.text(0)
# Determine item type and context
if item.parent() is None:
# Top-level item
if item.childCount() > 0:
state = "expanded" if item.isExpanded() else "collapsed"
return f"{text} ({item.childCount()} items, {state})"
else:
return text
else:
# Child item
depth = self.getItemDepth(item)
if depth == 1:
return f"Item: {text}"
else:
return f"Level {depth}: {text}"
def getItemDepth(self, item: QTreeWidgetItem) -> int:
"""Get the nesting depth of an item"""
depth = 0
current = item
while current.parent():
depth += 1
current = current.parent()
return depth
def getRootParent(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the root parent (top-level item) of any item"""
current = item
while current.parent():
current = current.parent()
return current
def isItemNavigable(self, item: QTreeWidgetItem) -> bool:
"""Check if an item is safe to navigate to"""
if not item:
return False
# Check if item is enabled and selectable, and not hidden
flags = item.flags()
isHidden = item.data(0, Qt.AccessibleDescriptionRole) == "hidden"
isEnabled = bool(flags & Qt.ItemIsEnabled)
isSelectable = bool(flags & Qt.ItemIsSelectable)
return isEnabled and isSelectable and not isHidden

1
src/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Subsonic API client module"""

23
src/api/auth.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Subsonic API authentication utilities
"""
import hashlib
import random
import string
class SubsonicAuth:
"""Subsonic authentication handler using token + salt method"""
@staticmethod
def generateSalt(length: int = 16) -> str:
"""Generate random salt string"""
chars = string.ascii_letters + string.digits
return ''.join(random.choice(chars) for _ in range(length))
@staticmethod
def generateToken(password: str, salt: str) -> str:
"""Generate MD5 token from password and salt"""
toHash = password + salt
return hashlib.md5(toHash.encode('utf-8')).hexdigest()

562
src/api/client.py Normal file
View File

@@ -0,0 +1,562 @@
"""
Subsonic API client for Navidrome communication
"""
from typing import Optional, List, Dict, Any
from urllib.parse import urlencode
import requests
from .auth import SubsonicAuth
from .models import Artist, Album, Song, Playlist, Genre
class SubsonicError(Exception):
"""Subsonic API error"""
ERROR_CODES = {
0: "Generic error",
10: "Required parameter missing",
20: "Incompatible client version",
30: "Incompatible server version",
40: "Wrong username or password",
41: "Token authentication not supported",
50: "User not authorized",
60: "Trial expired",
70: "Data not found"
}
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(f"[{code}] {message}")
class SubsonicClient:
"""Subsonic API client for Navidrome communication"""
API_VERSION = '1.16.1'
CLIENT_NAME = 'NaviPy'
def __init__(self, serverUrl: str, username: str, password: str):
"""
Initialize the Subsonic client
Args:
serverUrl: Base URL of the Navidrome server (e.g., https://music.example.com)
username: Navidrome username
password: Navidrome password
"""
self.serverUrl = serverUrl.rstrip('/')
self.username = username
self.password = password
self.session = requests.Session()
self.session.headers.update({
'User-Agent': f'{self.CLIENT_NAME}/1.0'
})
def _makeRequest(self, endpoint: str, params: dict = None) -> dict:
"""
Make authenticated API request
Args:
endpoint: API endpoint (e.g., 'ping', 'getArtists')
params: Additional parameters for the request
Returns:
dict: Parsed API response
Raises:
SubsonicError: If the API returns an error
requests.RequestException: If the network request fails
"""
salt = SubsonicAuth.generateSalt()
token = SubsonicAuth.generateToken(self.password, salt)
baseParams = {
'u': self.username,
't': token,
's': salt,
'v': self.API_VERSION,
'c': self.CLIENT_NAME,
'f': 'json'
}
if params:
baseParams.update(params)
url = f"{self.serverUrl}/rest/{endpoint}"
response = self.session.get(url, params=baseParams, timeout=30)
response.raise_for_status()
data = response.json()
# Subsonic wraps responses in 'subsonic-response'
subsonicResponse = data.get('subsonic-response', {})
if subsonicResponse.get('status') == 'failed':
error = subsonicResponse.get('error', {})
raise SubsonicError(
code=error.get('code', 0),
message=error.get('message', 'Unknown error')
)
return subsonicResponse
def ping(self) -> bool:
"""
Test connection to the server
Returns:
bool: True if connection successful
"""
response = self._makeRequest('ping')
return response.get('status') == 'ok'
def getStreamUrl(self, songId: str, format: str = None, maxBitRate: int = None) -> str:
"""
Generate streaming URL for a song
Args:
songId: ID of the song to stream
format: Optional transcoding format (mp3, opus, etc.)
maxBitRate: Optional max bitrate for transcoding
Returns:
str: Full streaming URL with authentication
"""
salt = SubsonicAuth.generateSalt()
token = SubsonicAuth.generateToken(self.password, salt)
params = {
'u': self.username,
't': token,
's': salt,
'v': self.API_VERSION,
'c': self.CLIENT_NAME,
'id': songId
}
if format:
params['format'] = format
if maxBitRate:
params['maxBitRate'] = maxBitRate
return f"{self.serverUrl}/rest/stream?{urlencode(params)}"
def getCoverArtUrl(self, coverId: str, size: int = None) -> str:
"""
Generate cover art URL
Args:
coverId: Cover art ID
size: Optional size in pixels
Returns:
str: Full cover art URL with authentication
"""
salt = SubsonicAuth.generateSalt()
token = SubsonicAuth.generateToken(self.password, salt)
params = {
'u': self.username,
't': token,
's': salt,
'v': self.API_VERSION,
'c': self.CLIENT_NAME,
'id': coverId
}
if size:
params['size'] = size
return f"{self.serverUrl}/rest/getCoverArt?{urlencode(params)}"
# ==================== Browsing ====================
def getArtists(self, musicFolderId: str = None) -> List[Artist]:
"""
Get all artists
Args:
musicFolderId: Optional music folder to filter by
Returns:
List of Artist objects
"""
params = {}
if musicFolderId:
params['musicFolderId'] = musicFolderId
response = self._makeRequest('getArtists', params)
artists = []
artistsData = response.get('artists', {})
for index in artistsData.get('index', []):
for artistData in index.get('artist', []):
artists.append(Artist.fromDict(artistData))
return artists
def getArtist(self, artistId: str) -> Dict[str, Any]:
"""
Get artist details including albums
Args:
artistId: ID of the artist
Returns:
dict with 'artist' (Artist) and 'albums' (List[Album])
"""
response = self._makeRequest('getArtist', {'id': artistId})
artistData = response.get('artist', {})
artist = Artist.fromDict(artistData)
albums = [Album.fromDict(a) for a in artistData.get('album', [])]
return {'artist': artist, 'albums': albums}
def getAlbum(self, albumId: str) -> Dict[str, Any]:
"""
Get album details including songs
Args:
albumId: ID of the album
Returns:
dict with 'album' (Album) and 'songs' (List[Song])
"""
response = self._makeRequest('getAlbum', {'id': albumId})
albumData = response.get('album', {})
album = Album.fromDict(albumData)
songs = [Song.fromDict(s) for s in albumData.get('song', [])]
return {'album': album, 'songs': songs}
def getSong(self, songId: str) -> Song:
"""
Get song details
Args:
songId: ID of the song
Returns:
Song object
"""
response = self._makeRequest('getSong', {'id': songId})
return Song.fromDict(response.get('song', {}))
def getGenres(self) -> List[Genre]:
"""
Get all genres
Returns:
List of Genre objects
"""
response = self._makeRequest('getGenres')
genresData = response.get('genres', {})
return [Genre.fromDict(g) for g in genresData.get('genre', [])]
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
"""
Get songs by genre
Args:
genre: Genre name
count: Maximum number of songs to return
offset: Pagination offset
Returns:
List of Song objects
"""
params = {
'genre': genre,
'count': count,
'offset': offset
}
response = self._makeRequest('getSongsByGenre', params)
songsData = response.get('songsByGenre', {})
return [Song.fromDict(s) for s in songsData.get('song', [])]
def getRandomSongs(self, size: int = 10, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Song]:
"""
Get random songs
Args:
size: Number of songs to return
genre: Optional genre filter
fromYear: Optional start year filter
toYear: Optional end year filter
Returns:
List of Song objects
"""
params = {'size': size}
if genre:
params['genre'] = genre
if fromYear:
params['fromYear'] = fromYear
if toYear:
params['toYear'] = toYear
response = self._makeRequest('getRandomSongs', params)
songsData = response.get('randomSongs', {})
return [Song.fromDict(s) for s in songsData.get('song', [])]
# ==================== Search ====================
def search(self, query: str, artistCount: int = 20,
albumCount: int = 20, songCount: int = 20) -> Dict[str, List]:
"""
Search for artists, albums, and songs
Args:
query: Search query
artistCount: Max artists to return
albumCount: Max albums to return
songCount: Max songs to return
Returns:
dict with 'artists', 'albums', 'songs' lists
"""
params = {
'query': query,
'artistCount': artistCount,
'albumCount': albumCount,
'songCount': songCount
}
response = self._makeRequest('search3', params)
searchResult = response.get('searchResult3', {})
return {
'artists': [Artist.fromDict(a) for a in searchResult.get('artist', [])],
'albums': [Album.fromDict(a) for a in searchResult.get('album', [])],
'songs': [Song.fromDict(s) for s in searchResult.get('song', [])]
}
# ==================== Playlists ====================
def getPlaylists(self) -> List[Playlist]:
"""
Get all playlists
Returns:
List of Playlist objects
"""
response = self._makeRequest('getPlaylists')
playlistsData = response.get('playlists', {})
return [Playlist.fromDict(p) for p in playlistsData.get('playlist', [])]
def getPlaylist(self, playlistId: str) -> Playlist:
"""
Get playlist details including songs
Args:
playlistId: ID of the playlist
Returns:
Playlist object with songs populated
"""
response = self._makeRequest('getPlaylist', {'id': playlistId})
return Playlist.fromDict(response.get('playlist', {}))
def createPlaylist(self, name: str, songIds: List[str] = None) -> Playlist:
"""
Create a new playlist
Args:
name: Playlist name
songIds: Optional list of song IDs to add
Returns:
Created Playlist object
"""
params = {'name': name}
if songIds:
params['songId'] = songIds
response = self._makeRequest('createPlaylist', params)
return Playlist.fromDict(response.get('playlist', {}))
def updatePlaylist(self, playlistId: str, name: str = None,
songIdsToAdd: List[str] = None,
songIndexesToRemove: List[int] = None) -> None:
"""
Update a playlist
Args:
playlistId: ID of the playlist
name: New name (optional)
songIdsToAdd: Song IDs to add (optional)
songIndexesToRemove: Song indexes to remove (optional)
"""
params = {'playlistId': playlistId}
if name:
params['name'] = name
if songIdsToAdd:
params['songIdToAdd'] = songIdsToAdd
if songIndexesToRemove:
params['songIndexToRemove'] = songIndexesToRemove
self._makeRequest('updatePlaylist', params)
def deletePlaylist(self, playlistId: str) -> None:
"""
Delete a playlist
Args:
playlistId: ID of the playlist to delete
"""
self._makeRequest('deletePlaylist', {'id': playlistId})
# ==================== User Actions ====================
def star(self, itemId: str, itemType: str = 'song') -> None:
"""
Star (favorite) an item
Args:
itemId: ID of the item
itemType: Type of item ('song', 'album', 'artist')
"""
paramName = {
'song': 'id',
'album': 'albumId',
'artist': 'artistId'
}.get(itemType, 'id')
self._makeRequest('star', {paramName: itemId})
def unstar(self, itemId: str, itemType: str = 'song') -> None:
"""
Unstar (unfavorite) an item
Args:
itemId: ID of the item
itemType: Type of item ('song', 'album', 'artist')
"""
paramName = {
'song': 'id',
'album': 'albumId',
'artist': 'artistId'
}.get(itemType, 'id')
self._makeRequest('unstar', {paramName: itemId})
def setRating(self, itemId: str, rating: int) -> None:
"""
Set rating for an item (1-5 stars, 0 to remove)
Args:
itemId: ID of the item
rating: Rating value (0-5)
"""
self._makeRequest('setRating', {'id': itemId, 'rating': rating})
def scrobble(self, songId: str, submission: bool = True) -> None:
"""
Scrobble a song (register as played)
Args:
songId: ID of the song
submission: True for full scrobble, False for "now playing"
"""
self._makeRequest('scrobble', {'id': songId, 'submission': submission})
# ==================== Play Queue ====================
def getPlayQueue(self) -> Dict[str, Any]:
"""
Get the saved play queue
Returns:
dict with 'songs' (List[Song]), 'current' (str), 'position' (int)
"""
response = self._makeRequest('getPlayQueue')
queueData = response.get('playQueue', {})
return {
'songs': [Song.fromDict(s) for s in queueData.get('entry', [])],
'current': queueData.get('current'),
'position': queueData.get('position', 0)
}
def savePlayQueue(self, songIds: List[str], current: str = None,
position: int = None) -> None:
"""
Save the current play queue
Args:
songIds: List of song IDs in the queue
current: ID of currently playing song
position: Position in current song (milliseconds)
"""
params = {'id': songIds}
if current:
params['current'] = current
if position is not None:
params['position'] = position
self._makeRequest('savePlayQueue', params)
# ==================== Starred/Favorites ====================
def getStarred(self) -> Dict[str, List]:
"""
Get all starred items
Returns:
dict with 'artists', 'albums', 'songs' lists
"""
response = self._makeRequest('getStarred2')
starredData = response.get('starred2', {})
return {
'artists': [Artist.fromDict(a) for a in starredData.get('artist', [])],
'albums': [Album.fromDict(a) for a in starredData.get('album', [])],
'songs': [Song.fromDict(s) for s in starredData.get('song', [])]
}
# ==================== Lyrics ====================
def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]:
"""
Get lyrics for a song
Args:
artist: Artist name
title: Song title
Returns:
Lyrics text or None if not found
"""
params = {}
if artist:
params['artist'] = artist
if title:
params['title'] = title
response = self._makeRequest('getLyrics', params)
lyricsData = response.get('lyrics', {})
return lyricsData.get('value')
# ==================== System ====================
def startScan(self) -> None:
"""Start a library scan"""
self._makeRequest('startScan')
def getScanStatus(self) -> Dict[str, Any]:
"""
Get library scan status
Returns:
dict with 'scanning' (bool) and 'count' (int)
"""
response = self._makeRequest('getScanStatus')
scanData = response.get('scanStatus', {})
return {
'scanning': scanData.get('scanning', False),
'count': scanData.get('count', 0)
}

214
src/api/models.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Data models for Subsonic API responses
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, List
@dataclass
class Artist:
"""Artist model"""
id: str
name: str
albumCount: int = 0
starred: Optional[datetime] = None
coverArt: Optional[str] = None
artistImageUrl: Optional[str] = None
@classmethod
def fromDict(cls, data: dict) -> 'Artist':
"""Create Artist from API response dict"""
starred = None
if data.get('starred'):
try:
starred = datetime.fromisoformat(data['starred'].replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
return cls(
id=str(data.get('id', '')),
name=data.get('name', 'Unknown Artist'),
albumCount=data.get('albumCount', 0),
starred=starred,
coverArt=data.get('coverArt'),
artistImageUrl=data.get('artistImageUrl')
)
@dataclass
class Album:
"""Album model"""
id: str
name: str
artist: str
artistId: Optional[str] = None
songCount: int = 0
duration: int = 0
year: Optional[int] = None
genre: Optional[str] = None
coverArt: Optional[str] = None
starred: Optional[datetime] = None
playCount: int = 0
userRating: int = 0
@classmethod
def fromDict(cls, data: dict) -> 'Album':
"""Create Album from API response dict"""
starred = None
if data.get('starred'):
try:
starred = datetime.fromisoformat(data['starred'].replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
return cls(
id=str(data.get('id', '')),
name=data.get('name', data.get('album', 'Unknown Album')),
artist=data.get('artist', 'Unknown Artist'),
artistId=data.get('artistId'),
songCount=data.get('songCount', 0),
duration=data.get('duration', 0),
year=data.get('year'),
genre=data.get('genre'),
coverArt=data.get('coverArt'),
starred=starred,
playCount=data.get('playCount', 0),
userRating=data.get('userRating', 0)
)
@dataclass
class Song:
"""Song/Track model"""
id: str
title: str
album: str
albumId: str
artist: str
artistId: Optional[str] = None
duration: int = 0
track: Optional[int] = None
discNumber: Optional[int] = None
year: Optional[int] = None
genre: Optional[str] = None
bitRate: Optional[int] = None
contentType: Optional[str] = None
suffix: Optional[str] = None
path: Optional[str] = None
coverArt: Optional[str] = None
starred: Optional[datetime] = None
userRating: int = 0
playCount: int = 0
bookmarkPosition: Optional[int] = None
@classmethod
def fromDict(cls, data: dict) -> 'Song':
"""Create Song from API response dict"""
starred = None
if data.get('starred'):
try:
starred = datetime.fromisoformat(data['starred'].replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
return cls(
id=str(data.get('id', '')),
title=data.get('title', 'Unknown Title'),
album=data.get('album', 'Unknown Album'),
albumId=str(data.get('albumId', '')),
artist=data.get('artist', 'Unknown Artist'),
artistId=data.get('artistId'),
duration=data.get('duration', 0),
track=data.get('track'),
discNumber=data.get('discNumber'),
year=data.get('year'),
genre=data.get('genre'),
bitRate=data.get('bitRate'),
contentType=data.get('contentType'),
suffix=data.get('suffix'),
path=data.get('path'),
coverArt=data.get('coverArt'),
starred=starred,
userRating=data.get('userRating', 0),
playCount=data.get('playCount', 0),
bookmarkPosition=data.get('bookmarkPosition')
)
@property
def durationFormatted(self) -> str:
"""Return duration as formatted string (MM:SS or HH:MM:SS)"""
if self.duration <= 0:
return "0:00"
hours = self.duration // 3600
minutes = (self.duration % 3600) // 60
seconds = self.duration % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
@dataclass
class Playlist:
"""Playlist model"""
id: str
name: str
songCount: int = 0
duration: int = 0
public: bool = False
owner: Optional[str] = None
created: Optional[datetime] = None
changed: Optional[datetime] = None
coverArt: Optional[str] = None
songs: List[Song] = field(default_factory=list)
@classmethod
def fromDict(cls, data: dict) -> 'Playlist':
"""Create Playlist from API response dict"""
created = None
changed = None
if data.get('created'):
try:
created = datetime.fromisoformat(data['created'].replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
if data.get('changed'):
try:
changed = datetime.fromisoformat(data['changed'].replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
songs = []
if 'entry' in data:
songs = [Song.fromDict(s) for s in data['entry']]
return cls(
id=str(data.get('id', '')),
name=data.get('name', 'Unnamed Playlist'),
songCount=data.get('songCount', 0),
duration=data.get('duration', 0),
public=data.get('public', False),
owner=data.get('owner'),
created=created,
changed=changed,
coverArt=data.get('coverArt'),
songs=songs
)
@dataclass
class Genre:
"""Genre model"""
name: str
songCount: int = 0
albumCount: int = 0
@classmethod
def fromDict(cls, data: dict) -> 'Genre':
"""Create Genre from API response dict"""
return cls(
name=data.get('value', data.get('name', 'Unknown Genre')),
songCount=data.get('songCount', 0),
albumCount=data.get('albumCount', 0)
)

1
src/config/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Configuration management module"""

168
src/config/settings.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Configuration management with XDG compliance
"""
import os
import json
from pathlib import Path
from typing import Optional, Dict, Any
APP_VENDOR = "stormux"
APP_NAME = "navipy"
def getConfigDir() -> Path:
"""Get XDG-compliant config directory"""
xdgConfig = os.environ.get('XDG_CONFIG_HOME')
baseDir = Path(xdgConfig) if xdgConfig else Path.home() / '.config'
configDir = baseDir / APP_VENDOR / APP_NAME
configDir.mkdir(parents=True, exist_ok=True)
return configDir
def getDataDir() -> Path:
"""Get XDG-compliant data directory"""
xdgData = os.environ.get('XDG_DATA_HOME')
baseDir = Path(xdgData) if xdgData else Path.home() / '.local' / 'share'
dataDir = baseDir / APP_VENDOR / APP_NAME
dataDir.mkdir(parents=True, exist_ok=True)
return dataDir
def getCacheDir() -> Path:
"""Get XDG-compliant cache directory"""
xdgCache = os.environ.get('XDG_CACHE_HOME')
baseDir = Path(xdgCache) if xdgCache else Path.home() / '.cache'
cacheDir = baseDir / APP_VENDOR / APP_NAME
cacheDir.mkdir(parents=True, exist_ok=True)
return cacheDir
class Settings:
"""Application settings manager"""
def __init__(self):
self.configDir = getConfigDir()
self.configFile = self.configDir / 'settings.json'
self.serversFile = self.configDir / 'servers.json'
self._legacyConfigDir = self._getLegacyConfigDir()
self._settings = self._loadSettings()
self._servers = self._loadServers()
def _getLegacyConfigDir(self) -> Path:
"""Legacy configuration path kept for backwards compatibility."""
xdgConfig = os.environ.get('XDG_CONFIG_HOME')
baseDir = Path(xdgConfig) if xdgConfig else Path.home() / '.config'
return baseDir / APP_NAME
def _loadSettings(self) -> Dict[str, Any]:
"""Load settings from file"""
defaults = {
'general': {
'defaultServer': None,
'startMinimized': False
},
'playback': {
'volume': 100,
'shuffle': False,
'repeat': 'none', # none, one, all
'gapless': True
},
'interface': {
'pageStep': 5,
'announceTrackChanges': True
}
}
for candidate in (self.configFile, self._legacyConfigDir / 'settings.json'):
if candidate.exists():
try:
with open(candidate, 'r', encoding='utf-8') as f:
loaded = json.load(f)
# Merge loaded settings with defaults
for section in defaults:
if section in loaded:
defaults[section].update(loaded[section])
return defaults
except (json.JSONDecodeError, IOError):
continue
return defaults
def _loadServers(self) -> Dict[str, Dict[str, str]]:
"""Load server configurations from file"""
for candidate in (self.serversFile, self._legacyConfigDir / 'servers.json'):
if candidate.exists():
try:
with open(candidate, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
continue
return {}
def save(self) -> None:
"""Save settings to file"""
try:
with open(self.configFile, 'w', encoding='utf-8') as f:
json.dump(self._settings, f, indent=2)
except IOError as e:
print(f"Failed to save settings: {e}")
def saveServers(self) -> None:
"""Save server configurations to file"""
try:
with open(self.serversFile, 'w', encoding='utf-8') as f:
json.dump(self._servers, f, indent=2)
except IOError as e:
print(f"Failed to save servers: {e}")
def get(self, section: str, key: str, default: Any = None) -> Any:
"""Get a setting value"""
return self._settings.get(section, {}).get(key, default)
def set(self, section: str, key: str, value: Any) -> None:
"""Set a setting value"""
if section not in self._settings:
self._settings[section] = {}
self._settings[section][key] = value
self.save()
# Server management
def getServers(self) -> Dict[str, Dict[str, str]]:
"""Get all server configurations"""
return self._servers
def getServer(self, name: str) -> Optional[Dict[str, str]]:
"""Get a specific server configuration"""
return self._servers.get(name)
def addServer(self, name: str, url: str, username: str, password: str) -> None:
"""Add or update a server configuration"""
self._servers[name] = {
'url': url,
'username': username,
'password': password
}
self.saveServers()
def removeServer(self, name: str) -> None:
"""Remove a server configuration"""
if name in self._servers:
del self._servers[name]
self.saveServers()
def getDefaultServer(self) -> Optional[str]:
"""Get the default server name"""
return self.get('general', 'defaultServer')
def setDefaultServer(self, name: str) -> None:
"""Set the default server"""
self.set('general', 'defaultServer', name)
def hasServers(self) -> bool:
"""Check if any servers are configured"""
return len(self._servers) > 0

View File

@@ -0,0 +1 @@
"""Integration helpers for NaviPy."""

389
src/integrations/mpris.py Normal file
View File

@@ -0,0 +1,389 @@
"""
MPRIS v2 integration using dbus-python with a GLib main loop.
We avoid QtDBus because some environments block Qt's D-Bus socket while the
standard dbus-python bindings work. The service exposes both interfaces on
`/org/mpris/MediaPlayer2` and runs a GLib loop in a background thread.
"""
from __future__ import annotations
import logging
import re
import threading
from typing import Callable, Dict, Optional
try:
import dbus
import dbus.service
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBUS_AVAILABLE = True
except Exception: # pragma: no cover - optional dependency
DBUS_AVAILABLE = False
dbus = None # type: ignore
DBusGMainLoop = None # type: ignore
GLib = None # type: ignore
from PySide6.QtCore import QMetaObject, Qt
logger = logging.getLogger(__name__)
def _sanitize_track_id(track_id: str) -> str:
return re.sub(r"[^A-Za-z0-9]", "_", track_id or "track")
if DBUS_AVAILABLE:
DBusObjectBase = dbus.service.Object
else:
class DBusObjectBase: # type: ignore
"""Placeholder when dbus-python is unavailable."""
pass
class MprisService(DBusObjectBase):
BUS_NAME = "org.mpris.MediaPlayer2.navipy"
OBJECT_PATH = "/org/mpris/MediaPlayer2"
def __init__(
self,
playback,
art_url_resolver: Optional[Callable[[str], Optional[str]]] = None,
raise_callback: Optional[Callable[[], None]] = None,
quit_callback: Optional[Callable[[], None]] = None,
on_volume_changed: Optional[Callable[[int], None]] = None,
on_shuffle_changed: Optional[Callable[[bool], None]] = None,
on_loop_changed: Optional[Callable[[str], None]] = None,
):
if not DBUS_AVAILABLE:
self.available = False
logger.warning("MPRIS disabled: dbus-python/GLib bindings not available.")
return
self.playback = playback
self.art_url_resolver = art_url_resolver
self.raise_callback = raise_callback
self.quit_callback = quit_callback
self.on_volume_changed = on_volume_changed
self.on_shuffle_changed = on_shuffle_changed
self.on_loop_changed = on_loop_changed
self._metadata: Dict[str, object] = {}
self._playbackState: str = "stopped"
self._currentTrackPath: dbus.ObjectPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")
self.available = False
self._loop = None
self._loop_thread: Optional[threading.Thread] = None
bus = self._connect_to_bus()
if not bus:
return
self.bus = bus
super().__init__(bus, self.OBJECT_PATH)
self.available = True
logger.info("MPRIS registered as %s", self.BUS_NAME)
self._start_loop()
self.updateMetadata(self.playback.currentSong())
self.updatePlaybackStatus(self._playbackState)
self.notifyCapabilities()
self.notifyVolumeChanged()
self.notifyShuffleChanged()
self.notifyLoopStatus()
# ===== DBus setup =====
def _connect_to_bus(self) -> Optional[dbus.SessionBus]:
"""Connect to the session bus, logging a warning on failure."""
try:
DBusGMainLoop(set_as_default=True)
bus = dbus.SessionBus()
bus.request_name(self.BUS_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE)
return bus
except Exception as e:
logger.warning("MPRIS disabled: %s", e)
return None
def _start_loop(self):
"""Run a GLib main loop in a background thread for DBus dispatch."""
self._loop = GLib.MainLoop()
self._loop_thread = threading.Thread(target=self._loop.run, daemon=True)
self._loop_thread.start()
def shutdown(self):
"""Stop the DBus loop."""
if self._loop:
self._loop.quit()
if self._loop_thread and self._loop_thread.is_alive():
self._loop_thread.join(timeout=1)
# ===== Helpers =====
def _call_on_main(self, func: Callable, *args):
"""Invoke playback operations on the Qt main thread."""
try:
QMetaObject.invokeMethod(self.playback, lambda: func(*args), Qt.QueuedConnection)
except Exception:
try:
func(*args)
except Exception as e:
logger.debug("MPRIS call failed: %s", e)
def _build_metadata(self, song) -> Dict[str, object]:
if not song:
self._currentTrackPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")
return dbus.Dictionary(
{"mpris:trackid": self._currentTrackPath},
signature="sv",
)
track_path = dbus.ObjectPath(f"/org/navipy/track/{_sanitize_track_id(song.id)}")
duration_us = int(song.duration * 1_000_000)
metadata: Dict[str, object] = {
"mpris:trackid": track_path,
"mpris:length": dbus.Int64(duration_us),
"xesam:title": dbus.String(song.title or "Unknown Title"),
"xesam:album": dbus.String(song.album or ""),
"xesam:artist": dbus.Array([song.artist], signature="s") if song.artist else dbus.Array([], signature="s"),
"xesam:genre": dbus.Array([song.genre], signature="s") if song.genre else dbus.Array([], signature="s"),
"xesam:trackNumber": dbus.Int32(song.track or 0),
"xesam:discNumber": dbus.Int32(song.discNumber or 0),
}
art_url = self._resolve_art_url(song.coverArt)
if art_url:
metadata["mpris:artUrl"] = art_url
self._currentTrackPath = track_path
return dbus.Dictionary(metadata, signature="sv")
def _resolve_art_url(self, cover_id: Optional[str]) -> Optional[str]:
if not cover_id or not self.art_url_resolver:
return None
try:
return self.art_url_resolver(cover_id)
except Exception:
return None
def _emit_properties_changed(self, interface: str, changes: Dict[str, object]):
"""Emit PropertiesChanged for the player object."""
try:
payload = dbus.Dictionary(changes, signature="sv")
invalidated = dbus.Array([], signature="s")
self.PropertiesChanged(interface, payload, invalidated)
except Exception as e:
logger.warning("Failed to emit PropertiesChanged (%s): %s", interface, e)
# ===== External notifications =====
def updateMetadata(self, song) -> None:
if not self.available:
return
self._metadata = self._build_metadata(song)
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Metadata": self._metadata})
def updatePlaybackStatus(self, state: str) -> None:
self._playbackState = state
if not self.available:
return
self._emit_properties_changed(
"org.mpris.MediaPlayer2.Player",
{"PlaybackStatus": self._player_props()["PlaybackStatus"]},
)
def notifyCapabilities(self) -> None:
if not self.available:
return
props = self._player_props()
self._emit_properties_changed(
"org.mpris.MediaPlayer2.Player",
{
"CanGoNext": props["CanGoNext"],
"CanGoPrevious": props["CanGoPrevious"],
"CanPlay": props["CanPlay"],
"CanPause": props["CanPause"],
"CanSeek": props["CanSeek"],
"CanControl": True,
},
)
def notifyVolumeChanged(self) -> None:
if self.available:
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Volume": self._player_props()["Volume"]})
def notifyShuffleChanged(self) -> None:
if self.available:
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Shuffle": self._player_props()["Shuffle"]})
def notifyLoopStatus(self) -> None:
if self.available:
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"LoopStatus": self._player_props()["LoopStatus"]})
# ===== Root interface properties =====
def _root_props(self) -> Dict[str, object]:
return {
"CanQuit": dbus.Boolean(True),
"CanRaise": dbus.Boolean(True),
"HasTrackList": dbus.Boolean(False),
"CanSetFullscreen": dbus.Boolean(False),
"Fullscreen": dbus.Boolean(False),
"DesktopEntry": dbus.String("navipy"),
"Identity": dbus.String("NaviPy"),
"SupportedUriSchemes": dbus.Array(["http", "https"], signature="s"),
"SupportedMimeTypes": dbus.Array(
["audio/mpeg", "audio/flac", "audio/ogg", "audio/mp4", "audio/aac", "audio/wav"],
signature="s",
),
}
# ===== Player interface properties =====
def _player_props(self) -> Dict[str, object]:
queue = getattr(self.playback, "queue", [])
mode = getattr(self.playback, "repeatMode", "none")
player = getattr(self.playback, "player", None)
audio_output = getattr(self.playback, "audioOutput", None)
return {
"PlaybackStatus": dbus.String({"playing": "Playing", "paused": "Paused", "stopped": "Stopped"}.get(self._playbackState, "Stopped")),
"LoopStatus": dbus.String({"one": "Track", "all": "Playlist", "none": "None"}.get(mode, "None")),
"Rate": dbus.Double(1.0),
"Shuffle": dbus.Boolean(bool(getattr(self.playback, "shuffleEnabled", False))),
"Metadata": self._metadata,
"Volume": dbus.Double(float(audio_output.volume()) if audio_output else 0.0),
"Position": dbus.Int64(int(player.position() * 1000) if player else 0),
"MinimumRate": dbus.Double(1.0),
"MaximumRate": dbus.Double(1.0),
"CanGoNext": dbus.Boolean(len(queue) > 1 or (len(queue) == 1 and mode != "none")),
"CanGoPrevious": dbus.Boolean(len(queue) > 0),
"CanPlay": dbus.Boolean(bool(queue)),
"CanPause": dbus.Boolean(bool(queue)),
"CanControl": dbus.Boolean(True),
"CanSeek": dbus.Boolean(bool(queue)),
"CanGoForward": dbus.Boolean(False),
"CanGoBackward": dbus.Boolean(False),
}
# ===== org.freedesktop.DBus.Properties =====
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v")
def Get(self, interface: str, prop: str):
if interface == "org.mpris.MediaPlayer2":
return self._root_props().get(prop)
if interface == "org.mpris.MediaPlayer2.Player":
return self._player_props().get(prop)
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Unknown interface {interface}")
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}")
def GetAll(self, interface: str):
if interface == "org.mpris.MediaPlayer2":
return self._root_props()
if interface == "org.mpris.MediaPlayer2.Player":
return self._player_props()
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Unknown interface {interface}")
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="ssv")
def Set(self, interface: str, prop: str, value):
if interface != "org.mpris.MediaPlayer2.Player":
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", "Properties on this interface are read-only")
if prop == "LoopStatus":
loop_status = str(value)
mode = {"Track": "one", "Playlist": "all", "None": "none"}.get(loop_status, "none")
if self.on_loop_changed:
self.on_loop_changed(loop_status)
else:
self._call_on_main(self.playback.setRepeatMode, mode)
self.notifyLoopStatus()
elif prop == "Shuffle":
enabled = bool(value)
if self.on_shuffle_changed:
self.on_shuffle_changed(enabled)
else:
self._call_on_main(self.playback.setShuffle, enabled)
self.notifyShuffleChanged()
elif prop == "Volume":
percent = int(max(0.0, min(1.0, float(value))) * 100)
if self.on_volume_changed:
self.on_volume_changed(percent)
else:
self._call_on_main(self.playback.setVolume, percent)
self.notifyVolumeChanged()
else:
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Property {prop} is not writable")
# ===== Signals =====
@dbus.service.signal(dbus_interface="org.freedesktop.DBus.Properties", signature="sa{sv}as")
def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties):
pass
@dbus.service.signal(dbus_interface="org.mpris.MediaPlayer2.Player", signature="x")
def Seeked(self, position):
pass
# ===== Root interface methods =====
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2")
def Raise(self):
if self.raise_callback:
self.raise_callback()
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2")
def Quit(self):
if self.quit_callback:
self.quit_callback()
# ===== Player methods =====
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def Next(self):
self._call_on_main(self.playback.next)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def Previous(self):
self._call_on_main(self.playback.previous)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def Pause(self):
player = getattr(self.playback, "player", None)
if player:
self._call_on_main(player.pause)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def PlayPause(self):
self._call_on_main(self.playback.togglePlayPause)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def Stop(self):
self._call_on_main(self.playback.stop)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
def Play(self):
self._call_on_main(self.playback.play)
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="x")
def Seek(self, offset: int):
player = getattr(self.playback, "player", None)
if not player:
return
current = player.position() * 1000 # to microseconds
target = max(0, current + offset)
self._call_on_main(self.playback.seek, int(target / 1000))
self.Seeked(dbus.Int64(target))
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="ox")
def SetPosition(self, track_id: dbus.ObjectPath, position: int):
if track_id != self._currentTrackPath:
return
self._call_on_main(self.playback.seek, int(position / 1000))
self.Seeked(dbus.Int64(position))
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="s")
def OpenUri(self, uri: str):
# Navidrome streams require authentication; ignore external open requests.
_ = uri

1248
src/main_window.py Normal file

File diff suppressed because it is too large Load Diff

1
src/managers/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Business logic managers module"""

View File

@@ -0,0 +1,206 @@
"""
Playback manager using PySide6 multimedia for streaming audio
"""
from __future__ import annotations
from typing import Callable, List, Optional
from random import randint
from PySide6.QtCore import QObject, QUrl, Signal
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from src.api.models import Song
class PlaybackManager(QObject):
"""
Handles the play queue and integrates QMediaPlayer with Subsonic streams.
A resolver callable is provided by the main window to convert a Song into
a streamable URL.
"""
trackChanged = Signal(Song)
queueChanged = Signal(list)
playbackStateChanged = Signal(str)
positionChanged = Signal(int, int) # position, duration
errorOccurred = Signal(str)
def __init__(self, parent: Optional[QObject] = None):
super().__init__(parent)
self.player = QMediaPlayer()
self.audioOutput = QAudioOutput()
self.player.setAudioOutput(self.audioOutput)
self.queue: List[Song] = []
self.currentIndex: int = -1
self.streamResolver: Optional[Callable[[Song], str]] = None
self.shuffleEnabled = False
self.repeatMode = "none" # none, one, all
self.player.playbackStateChanged.connect(self._handlePlaybackStateChanged)
self.player.positionChanged.connect(self._handlePositionChanged)
self.player.mediaStatusChanged.connect(self._handleMediaStatus)
self.player.errorOccurred.connect(self._handleError)
def setStreamResolver(self, resolver: Callable[[Song], str]) -> None:
"""Set resolver used to turn a Song into a playable URL."""
self.streamResolver = resolver
def setVolume(self, volumePercent: int) -> None:
"""Set audio output volume (0-100)."""
volumePercent = max(0, min(100, volumePercent))
self.audioOutput.setVolume(volumePercent / 100)
def enqueue(self, song: Song, playNow: bool = False) -> None:
"""Add a song to the queue, optionally starting playback immediately."""
self.queue.append(song)
self.queueChanged.emit(self.queue.copy())
if playNow or self.currentIndex == -1:
self.playIndex(len(self.queue) - 1)
def enqueueMany(self, songs: List[Song], playFirst: bool = False) -> None:
"""Add multiple songs to the queue."""
if not songs:
return
startIndex = len(self.queue)
self.queue.extend(songs)
self.queueChanged.emit(self.queue.copy())
if playFirst or self.currentIndex == -1:
self.playIndex(startIndex)
def clearQueue(self) -> None:
"""Clear the play queue and stop playback."""
self.queue = []
self.currentIndex = -1
self.player.stop()
self.queueChanged.emit(self.queue.copy())
self.playbackStateChanged.emit("stopped")
def playIndex(self, index: int) -> None:
"""Play the song at the given queue index."""
if not (0 <= index < len(self.queue)):
return
if not self.streamResolver:
self.errorOccurred.emit("No stream resolver configured.")
return
self.currentIndex = index
song = self.queue[index]
streamUrl = self.streamResolver(song)
self.player.setSource(QUrl(streamUrl))
self.player.play()
self.trackChanged.emit(song)
self.queueChanged.emit(self.queue.copy())
def togglePlayPause(self) -> None:
"""Toggle between play and pause."""
state = self.player.playbackState()
if state == QMediaPlayer.PlayingState:
self.player.pause()
elif state == QMediaPlayer.PausedState:
self.player.play()
else:
if self.currentIndex == -1 and self.queue:
self.playIndex(0)
elif self.currentIndex != -1:
self.player.play()
def play(self) -> None:
"""Start or resume playback without pausing when already playing."""
state = self.player.playbackState()
if state == QMediaPlayer.PlayingState:
return
if state == QMediaPlayer.PausedState:
self.player.play()
return
if self.currentIndex == -1 and self.queue:
self.playIndex(0)
elif self.currentIndex != -1:
self.player.play()
def stop(self) -> None:
"""Stop playback and reset position."""
self.player.stop()
self.playbackStateChanged.emit("stopped")
def next(self, fromAuto: bool = False) -> None:
"""Advance to the next track in the queue."""
if not self.queue:
return
if self.repeatMode == "one" and fromAuto:
self.playIndex(self.currentIndex)
return
if self.shuffleEnabled:
target = randint(0, len(self.queue) - 1)
else:
target = self.currentIndex + 1
if target >= len(self.queue):
if self.repeatMode == "all":
target = 0
else:
self.player.stop()
return
self.playIndex(target)
def previous(self) -> None:
"""Return to the previous track."""
if not self.queue:
return
target = self.currentIndex - 1
if target < 0:
if self.repeatMode == "all":
target = len(self.queue) - 1
else:
target = 0
self.playIndex(target)
def seek(self, positionMs: int) -> None:
"""Seek to a position within the current track."""
self.player.setPosition(max(0, positionMs))
def setShuffle(self, enabled: bool) -> None:
"""Enable or disable shuffle mode."""
self.shuffleEnabled = enabled
def setRepeatMode(self, mode: str) -> None:
"""Set repeat mode: none, one, or all."""
if mode in ("none", "one", "all"):
self.repeatMode = mode
def currentSong(self) -> Optional[Song]:
"""Get the currently playing song if available."""
if 0 <= self.currentIndex < len(self.queue):
return self.queue[self.currentIndex]
return None
# Signal handlers
def _handlePlaybackStateChanged(self, state) -> None:
"""Emit simplified playback state."""
mapping = {
QMediaPlayer.PlayingState: "playing",
QMediaPlayer.PausedState: "paused",
QMediaPlayer.StoppedState: "stopped"
}
self.playbackStateChanged.emit(mapping.get(state, "unknown"))
def _handlePositionChanged(self, position: int) -> None:
"""Emit position updates along with duration."""
self.positionChanged.emit(position, self.player.duration())
def _handleMediaStatus(self, status) -> None:
"""Automatically progress when a track finishes."""
from PySide6.QtMultimedia import QMediaPlayer as MP # Local import avoids circular typing
if status == MP.EndOfMedia:
self.next(fromAuto=True)
def _handleError(self, error) -> None:
"""Surface player errors to the UI."""
if error:
description = self.player.errorString() or str(error)
self.errorOccurred.emit(description)

1
src/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Data models module"""

1
src/widgets/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""UI widgets module"""

View File

@@ -0,0 +1,105 @@
"""
Accessible text display dialog - single point of truth for showing text to screen reader users
Based on the implementation from Bifrost
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox
)
from PySide6.QtCore import Qt
class AccessibleTextDialog(QDialog):
"""
Single reusable dialog for displaying text content accessibly to screen readers.
Provides keyboard navigation, text selection, and proper focus management.
"""
def __init__(self, title: str, content: str, dialogType: str = "info", parent=None):
"""
Initialize accessible text dialog
Args:
title: Window title
content: Text content to display
dialogType: "info", "error", "success", or "warning" - affects accessible name
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
# Size based on content length
if len(content) > 500:
self.setMinimumSize(600, 400)
elif len(content) > 200:
self.setMinimumSize(500, 300)
else:
self.setMinimumSize(400, 200)
self.setupUi(content, dialogType)
def setupUi(self, content: str, dialogType: str):
"""Setup the dialog UI with accessible text widget"""
layout = QVBoxLayout(self)
# Create accessible text edit widget
self.textEdit = QTextEdit()
self.textEdit.setPlainText(content)
self.textEdit.setReadOnly(True)
# Set accessible name based on dialog type
accessibleNames = {
"info": "Information Text",
"error": "Error Details",
"warning": "Warning Information",
"success": "Success Information"
}
self.textEdit.setAccessibleName(accessibleNames.get(dialogType, "Dialog Text"))
# Enable keyboard text selection and navigation
self.textEdit.setTextInteractionFlags(
Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
)
layout.addWidget(self.textEdit)
# Button box
buttonBox = QDialogButtonBox(QDialogButtonBox.Ok)
buttonBox.accepted.connect(self.accept)
layout.addWidget(buttonBox)
# Focus the text edit first so user can immediately read content
self.textEdit.setFocus()
@classmethod
def showInfo(cls, title: str, content: str, parent=None):
"""Convenience method for info dialogs"""
dialog = cls(title, content, "info", parent)
dialog.exec()
@classmethod
def showError(cls, title: str, message: str, details: str = None, parent=None):
"""Convenience method for error dialogs with optional details"""
if details:
content = f"{message}\n\nError Details:\n{details}"
else:
content = message
dialog = cls(title, content, "error", parent)
dialog.exec()
@classmethod
def showSuccess(cls, title: str, message: str, details: str = None, parent=None):
"""Convenience method for success dialogs with optional details"""
if details:
content = f"{message}\n\n{details}"
else:
content = message
dialog = cls(title, content, "success", parent)
dialog.exec()
@classmethod
def showWarning(cls, title: str, content: str, parent=None):
"""Convenience method for warning dialogs"""
dialog = cls(title, content, "warning", parent)
dialog.exec()

View File

@@ -0,0 +1,108 @@
"""
Accessible search dialog for finding tracks quickly.
"""
from __future__ import annotations
from typing import List
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
)
from src.api.client import SubsonicClient
from src.api.models import Song
from src.widgets.accessible_text_dialog import AccessibleTextDialog
class SearchDialog(QDialog):
"""Simple dialog for searching the Navidrome library."""
songActivated = Signal(Song)
def __init__(self, client: SubsonicClient, parent=None):
super().__init__(parent)
self.client = client
self.setWindowTitle("Search Library")
self.setMinimumSize(500, 420)
self.setupUi()
def setupUi(self):
layout = QVBoxLayout(self)
self.instructions = QLabel("Enter a song, artist, or album title. Press Enter to play results.")
self.instructions.setAccessibleName("Search Instructions")
self.instructions.setWordWrap(True)
layout.addWidget(self.instructions)
inputLayout = QHBoxLayout()
self.queryEdit = QLineEdit()
self.queryEdit.setAccessibleName("Search Query")
self.queryEdit.returnPressed.connect(self.runSearch)
inputLayout.addWidget(self.queryEdit)
self.searchButton = QPushButton("&Search")
self.searchButton.setAccessibleName("Start Search")
self.searchButton.clicked.connect(self.runSearch)
inputLayout.addWidget(self.searchButton)
layout.addLayout(inputLayout)
self.resultsList = QListWidget()
self.resultsList.setAccessibleName("Search Results")
self.resultsList.itemActivated.connect(self.activateSelection)
layout.addWidget(self.resultsList)
self.statusLabel = QLabel("")
self.statusLabel.setAccessibleName("Search Status")
layout.addWidget(self.statusLabel)
buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
buttonBox.rejected.connect(self.reject)
layout.addWidget(buttonBox)
def runSearch(self):
"""Run the Subsonic search and populate results."""
query = self.queryEdit.text().strip()
if not query:
self.statusLabel.setText("Enter a search term to begin.")
return
self.statusLabel.setText("Searching...")
self.resultsList.clear()
try:
results = self.client.search(query, artistCount=0, albumCount=0, songCount=50)
songs: List[Song] = results.get("songs", [])
if not songs:
self.statusLabel.setText("No songs found.")
return
for song in songs:
text = f"{song.title}{song.artist} ({song.album}, {song.durationFormatted})"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, song)
item.setData(Qt.AccessibleTextRole, text)
self.resultsList.addItem(item)
self.statusLabel.setText(f"Found {len(songs)} songs. Activate a result to play.")
self.resultsList.setCurrentRow(0)
self.resultsList.setFocus()
except Exception as e:
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
self.statusLabel.setText("Search failed.")
def activateSelection(self, item: QListWidgetItem):
"""Emit the chosen song and close the dialog."""
song = item.data(Qt.UserRole)
if isinstance(song, Song):
self.songActivated.emit(song)
self.accept()

View File

@@ -0,0 +1,206 @@
"""
Server connection dialog for adding/editing Navidrome servers
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox
)
from PySide6.QtCore import Qt
from src.api.client import SubsonicClient, SubsonicError
from src.config.settings import Settings
class ServerDialog(QDialog):
"""Dialog for configuring a Navidrome server connection"""
def __init__(self, settings: Settings, serverName: str = None, parent=None):
"""
Initialize the server dialog
Args:
settings: Application settings instance
serverName: Name of existing server to edit (None for new server)
parent: Parent widget
"""
super().__init__(parent)
self.settings = settings
self.editingServer = serverName
self.isNew = serverName is None
self.setWindowTitle("Add Server" if self.isNew else "Edit Server")
self.setModal(True)
self.setMinimumWidth(400)
self.setupUi()
self.loadExistingData()
def setupUi(self):
"""Setup the dialog UI"""
layout = QVBoxLayout(self)
# Form layout for inputs
formLayout = QFormLayout()
# Server name
self.nameEdit = QLineEdit()
self.nameEdit.setAccessibleName("Server Name")
self.nameEdit.setAccessibleDescription("A friendly name for this server")
nameLabel = QLabel("&Name:")
nameLabel.setBuddy(self.nameEdit)
formLayout.addRow(nameLabel, self.nameEdit)
# Server URL
self.urlEdit = QLineEdit()
self.urlEdit.setAccessibleName("Server URL")
self.urlEdit.setAccessibleDescription("The full URL to your Navidrome server, for example https://music.example.com")
self.urlEdit.setPlaceholderText("https://music.example.com")
urlLabel = QLabel("&URL:")
urlLabel.setBuddy(self.urlEdit)
formLayout.addRow(urlLabel, self.urlEdit)
# Username
self.usernameEdit = QLineEdit()
self.usernameEdit.setAccessibleName("Username")
usernameLabel = QLabel("U&sername:")
usernameLabel.setBuddy(self.usernameEdit)
formLayout.addRow(usernameLabel, self.usernameEdit)
# Password
self.passwordEdit = QLineEdit()
self.passwordEdit.setEchoMode(QLineEdit.Password)
self.passwordEdit.setAccessibleName("Password")
passwordLabel = QLabel("&Password:")
passwordLabel.setBuddy(self.passwordEdit)
formLayout.addRow(passwordLabel, self.passwordEdit)
# Set as default checkbox
self.defaultCheckbox = QCheckBox("Set as &default server")
self.defaultCheckbox.setAccessibleName("Set as default server")
formLayout.addRow("", self.defaultCheckbox)
layout.addLayout(formLayout)
# Status label for connection test
self.statusLabel = QLabel("")
self.statusLabel.setAccessibleName("Connection Status")
self.statusLabel.setWordWrap(True)
layout.addWidget(self.statusLabel)
# Button layout
buttonLayout = QHBoxLayout()
self.testButton = QPushButton("&Test Connection")
self.testButton.setAccessibleName("Test Connection")
self.testButton.clicked.connect(self.testConnection)
buttonLayout.addWidget(self.testButton)
buttonLayout.addStretch()
self.cancelButton = QPushButton("&Cancel")
self.cancelButton.setAccessibleName("Cancel")
self.cancelButton.clicked.connect(self.reject)
buttonLayout.addWidget(self.cancelButton)
self.saveButton = QPushButton("&Save")
self.saveButton.setAccessibleName("Save")
self.saveButton.setDefault(True)
self.saveButton.clicked.connect(self.saveServer)
buttonLayout.addWidget(self.saveButton)
layout.addLayout(buttonLayout)
# Set initial focus
self.nameEdit.setFocus()
def loadExistingData(self):
"""Load existing server data if editing"""
if not self.isNew and self.editingServer:
server = self.settings.getServer(self.editingServer)
if server:
self.nameEdit.setText(self.editingServer)
self.nameEdit.setReadOnly(True) # Can't change name when editing
self.urlEdit.setText(server.get('url', ''))
self.usernameEdit.setText(server.get('username', ''))
self.passwordEdit.setText(server.get('password', ''))
# Check if this is the default server
defaultServer = self.settings.getDefaultServer()
self.defaultCheckbox.setChecked(defaultServer == self.editingServer)
def testConnection(self):
"""Test connection to the server"""
url = self.urlEdit.text().strip()
username = self.usernameEdit.text().strip()
password = self.passwordEdit.text()
if not url or not username or not password:
self.statusLabel.setText("Please fill in all fields")
return
self.statusLabel.setText("Testing connection...")
self.testButton.setEnabled(False)
try:
client = SubsonicClient(url, username, password)
if client.ping():
self.statusLabel.setText("Connection successful!")
else:
self.statusLabel.setText("Connection failed: Invalid response")
except SubsonicError as e:
self.statusLabel.setText(f"Connection failed: {e.message}")
except Exception as e:
self.statusLabel.setText(f"Connection failed: {str(e)}")
finally:
self.testButton.setEnabled(True)
def saveServer(self):
"""Save the server configuration"""
name = self.nameEdit.text().strip()
url = self.urlEdit.text().strip()
username = self.usernameEdit.text().strip()
password = self.passwordEdit.text()
# Validate inputs
if not name:
QMessageBox.warning(self, "Validation Error", "Please enter a server name")
self.nameEdit.setFocus()
return
if not url:
QMessageBox.warning(self, "Validation Error", "Please enter the server URL")
self.urlEdit.setFocus()
return
if not username:
QMessageBox.warning(self, "Validation Error", "Please enter a username")
self.usernameEdit.setFocus()
return
if not password:
QMessageBox.warning(self, "Validation Error", "Please enter a password")
self.passwordEdit.setFocus()
return
# Check for duplicate name when adding new server
if self.isNew and self.settings.getServer(name):
QMessageBox.warning(
self, "Validation Error",
f"A server named '{name}' already exists. Please choose a different name."
)
self.nameEdit.setFocus()
return
# Save the server
self.settings.addServer(name, url, username, password)
# Set as default if checked
if self.defaultCheckbox.isChecked():
self.settings.setDefaultServer(name)
self.accept()
def getServerName(self) -> str:
"""Get the name of the saved server"""
return self.nameEdit.text().strip()