diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index b1ff66f3..44da0bd3 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -2,7 +2,23 @@ import javafx.application.Application; +/** + * Application entry point for the myPod application. + * + *

This class contains the {@code main} method and is responsible for + * bootstrapping the JavaFX runtime. It delegates control to the + * {@link MyPod} JavaFX {@link Application} implementation.

+ * + *

No application logic is handled here; this class exists solely to + * provide a clean and explicit startup entry point.

+ */ public class App { + + /** + * Launches the JavaFX application. + * + * @param args command-line arguments passed to the application + */ public static void main(String[] args) { Application.launch(MyPod.class, args); } diff --git a/src/main/java/org/example/DatabaseInitializer.java b/src/main/java/org/example/DatabaseInitializer.java index 923f2291..c98771f6 100644 --- a/src/main/java/org/example/DatabaseInitializer.java +++ b/src/main/java/org/example/DatabaseInitializer.java @@ -10,6 +10,19 @@ import java.util.List; +/** + * Initializes and populates the application database. + * + *

This class is responsible for:

+ * + * + *

The initializer is designed to be idempotent: data is only inserted + * if the database is empty or missing required entities.

+ */ public class DatabaseInitializer { private static final Logger logger = LoggerFactory.getLogger(DatabaseInitializer.class); @@ -21,6 +34,15 @@ public class DatabaseInitializer { private final ArtistRepository artistRepo; private final PlaylistRepository playlistRepo; + /** + * Creates a new database initializer. + * + * @param apiClient client used to fetch data from the iTunes API + * @param songRepo repository for {@link Song} entities + * @param albumRepo repository for {@link Album} entities + * @param artistRepo repository for {@link Artist} entities + * @param playlistRepo repository for {@link Playlist} entities + */ public DatabaseInitializer(ItunesApiClient apiClient, SongRepository songRepo, AlbumRepository albumRepo, ArtistRepository artistRepo, PlaylistRepository playlistRepo) { this.apiClient = apiClient; this.songRepo = songRepo; @@ -29,9 +51,21 @@ public DatabaseInitializer(ItunesApiClient apiClient, SongRepository songRepo, A this.playlistRepo = playlistRepo; } + /** + * Initializes the database with music data and default playlists. + * + *

If the song table is empty, a predefined set of artist searches + * is executed against the iTunes API. The resulting artists, albums, + * and songs are persisted while avoiding duplicates.

+ * + *

The method also ensures that required default playlists + * ("Library" and "Favorites") exist.

+ * + * @throws RuntimeException if data fetching or persistence fails + */ public void init() { - // Check if there is data already, fill if empty - if (songRepo.count() == 0) { + // Check if database is populated, populate if empty + if (songRepo.count() == 0) { // Limited artist set due to project scope List searches = List.of("the+war+on+drugs", "refused", "thrice", @@ -69,12 +103,12 @@ public void init() { } } - if (!playlistRepo.existsByUniqueId(1L)) { // Finns det en playlist borde det vara "Bibliotek" + // Ensure default playlists exist + if (!playlistRepo.existsByUniqueId(1L)) { Playlist library = playlistRepo.createPlaylist("Library"); playlistRepo.addSongs(library, songRepo.findAll()); - //Lägger bara till låtar som fanns innan listan, om fler "laddas ner" behövs de manuellt läggas till } - if (!playlistRepo.existsByUniqueId(2L)) { // Finns det två playlist borde den andra vara "Favoriter" + if (!playlistRepo.existsByUniqueId(2L)) { playlistRepo.createPlaylist("Favorites"); } } diff --git a/src/main/java/org/example/EntityManagerFactoryProvider.java b/src/main/java/org/example/EntityManagerFactoryProvider.java index 892b7d35..4c664591 100644 --- a/src/main/java/org/example/EntityManagerFactoryProvider.java +++ b/src/main/java/org/example/EntityManagerFactoryProvider.java @@ -10,8 +10,32 @@ import java.util.List; import java.util.Map; +/** + * Factory utility for creating a JPA {@link EntityManagerFactory}. + * + *

This class programmatically configures Hibernate without relying + * on a {@code persistence.xml} file. All JPA entities are discovered + * automatically via classpath scanning.

+ * + *

The factory supports additional configuration properties that + * can be supplied at runtime.

+ */ public class EntityManagerFactoryProvider { + /** + * Creates and configures an {@link EntityManagerFactory}. + * + *

The method scans the specified entity package for classes + * annotated with {@link jakarta.persistence.Entity}, registers them + * with Hibernate, and applies the provided JDBC and Hibernate + * configuration properties.

+ * + * @param jdbcUrl JDBC connection URL + * @param username database username + * @param password database password + * @param extraProps additional Hibernate configuration properties + * @return a fully initialized {@link EntityManagerFactory} + */ public static EntityManagerFactory create( String jdbcUrl, String username, @@ -32,6 +56,15 @@ public static EntityManagerFactory create( return cfg.createEntityManagerFactory(); } + /** + * Scans the classpath for JPA entity classes. + * + *

All classes annotated with {@link Entity} within the specified + * package are discovered and loaded.

+ * + * @param pkg base package to scan + * @return list of entity classes + */ private static List> scanEntities(String pkg) { try (ScanResult scanResult = new ClassGraph() diff --git a/src/main/java/org/example/ItunesApiClient.java b/src/main/java/org/example/ItunesApiClient.java index ec628152..206f1685 100644 --- a/src/main/java/org/example/ItunesApiClient.java +++ b/src/main/java/org/example/ItunesApiClient.java @@ -16,21 +16,46 @@ import java.util.ArrayList; import java.util.List; -public class ItunesApiClient { +/** + * Client for interacting with the iTunes Search API. + * + *

This class is responsible for executing HTTP requests against + * Apple's public iTunes Search API and mapping the results into + * {@link ItunesDTO} objects.

+ * + *

It performs basic response validation and result filtering + * to ensure that only relevant data is returned.

+ */ +public class ItunesApiClient { private static final Logger logger = LoggerFactory.getLogger(ItunesApiClient.class); - private final HttpClient client; private final ObjectMapper mapper; + /** + * Creates a new iTunes API client. + * + *

Initializes an {@link HttpClient} and configures a Jackson + * {@link ObjectMapper} with Java Time support.

+ */ public ItunesApiClient() { this.client = HttpClient.newHttpClient(); this.mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); } + /** + * Searches for songs by artist name using the iTunes Search API. + * + *

The search results are filtered so that only songs whose + * artist name matches the provided term (after normalization) + * are returned.

+ * + * @param term artist search term + * @return list of matching {@link ItunesDTO} objects + * @throws Exception if the HTTP request or JSON parsing fails + */ public List searchSongs(String term) throws Exception { - String encodedTerm = URLEncoder.encode(term, StandardCharsets.UTF_8); String url = "https://itunes.apple.com/search?term=" + encodedTerm + "&entity=song&attribute=artistTerm&limit=20"; @@ -43,13 +68,13 @@ public List searchSongs(String term) throws Exception { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - // Kontrollera status + // Validate HTTP response if (response.statusCode() != 200) { logger.error("searchSongs: status code {}", response.statusCode()); - throw new RuntimeException("API-fel: " + response.statusCode()); + throw new RuntimeException("API error: " + response.statusCode()); } - // Parse JSON + // Parse JSON response JsonNode root = mapper.readTree(response.body()); JsonNode results = root.get("results"); if (results == null || !results.isArray()) { @@ -78,6 +103,16 @@ public List searchSongs(String term) throws Exception { return songs; } + /** + * Normalizes a string for comparison purposes. + * + *

The normalization process converts the string to lowercase, + * collapses whitespace and plus characters, and trims leading + * and trailing spaces.

+ * + * @param s input string + * @return normalized string, or an empty string if {@code s} is {@code null} + */ public String normalize(String s) { if (s == null) { return ""; diff --git a/src/main/java/org/example/ItunesDTO.java b/src/main/java/org/example/ItunesDTO.java index 96f5849f..5452c2f9 100644 --- a/src/main/java/org/example/ItunesDTO.java +++ b/src/main/java/org/example/ItunesDTO.java @@ -5,6 +5,14 @@ import java.net.URL; import java.time.LocalDate; +/** + * Data Transfer Object representing a single song result + * returned from the iTunes Search API. + * + *

This record is designed to map directly to the JSON structure + * returned by the API and is used as an intermediate representation + * before converting the data into domain entities.

+ */ @JsonIgnoreProperties(ignoreUnknown = true) public record ItunesDTO(Long artistId, Long collectionId, @@ -20,6 +28,11 @@ public record ItunesDTO(Long artistId, URL artworkUrl100, String previewUrl) { + /** + * Extracts the release year from the release date. + * + * @return release year, or {@code 0} if the release date is unknown + */ public int releaseYear() { return releaseDate != null ? releaseDate.getYear() : 0; } diff --git a/src/main/java/org/example/ItunesPlayList.java b/src/main/java/org/example/ItunesPlayList.java index c0692afa..08d65760 100644 --- a/src/main/java/org/example/ItunesPlayList.java +++ b/src/main/java/org/example/ItunesPlayList.java @@ -21,95 +21,142 @@ import java.util.List; /** - * Huvudklass för GUI:t. Hanterar visning av bibliotek, spellistor och sökning. + * Main JavaFX UI class for playlist and library management. + * + *

+ * This class is responsible for constructing and displaying the graphical + * user interface, including playlist navigation, song tables, search + * functionality, and context menus for playlist operations. + *

+ * + *

+ * The UI communicates with the persistence layer exclusively through + * {@link PlaylistRepository}. + *

*/ public class ItunesPlayList { - private static final Logger logger = LoggerFactory.getLogger(ItunesPlayList.class); - private final PlaylistRepository pri; - private Runnable onUpdateCallback; + /** + * Creates a new {@code ItunesPlayList}. + * + * @param playlistRepository repository used for playlist persistence operations + */ + public ItunesPlayList(PlaylistRepository playlistRepository) { + this.pri = playlistRepository; + } + + /** + * Registers a callback that is invoked when the UI state changes and + * external components need to refresh. + * + * @param callback the callback to invoke on update + */ public void setOnUpdate(Runnable callback) { this.onUpdateCallback = callback; } + /** + * Triggers the registered update callback, if present. + */ private void refresh() { if (onUpdateCallback != null) { onUpdateCallback.run(); } } - public ItunesPlayList(PlaylistRepository playlistRepository) { - this.pri = playlistRepository; - } - - // --- DATAMODELL --- + // --------------------------------------------------------------------- + // Data model + // --------------------------------------------------------------------- - // En lista med alla playlist som finns i databasen + /** + * Observable list containing all playlists loaded from the database. + */ private final ObservableList allPlaylistList = FXCollections.observableArrayList(); - // --- GUI KOMPONENTER --- + // --------------------------------------------------------------------- + // UI components + // --------------------------------------------------------------------- - // Tabellen i mitten som visar låtarna + /** + * Table displaying the songs of the selected playlist. + */ private final TableView songTable = new TableView<>(); - // Listan till vänster där man väljer spellista + /** + * List view displaying available playlists. + */ private final ListView sourceList = new ListView<>(); - // Textfält för den "digitala displayen" högst upp + /** + * Text elements used in the LCD-style display at the top of the UI. + */ private Text lcdTitle = new Text("myTunes"); - private Text lcdArtist = new Text("Choose library or playlist"); + private Text lcdArtist = new Text("Choose Library or playlist"); /** - * Bygger upp hela gränssnittet och visar fönstret. + * Builds and displays the complete application window. * + *

+ * This method initializes all UI components, loads playlists asynchronously, + * and wires event handlers for user interaction. + *

*/ public void showLibrary() { - Stage stage = new Stage(); - // Lägg till existerande playlist i vår lokala lista (non-blocking) + // Load playlists asynchronously to avoid blocking the JavaFX thread new Thread(() -> { - List pls = pri.findAll(); - javafx.application.Platform.runLater(() -> allPlaylistList.setAll(pls)); + try { + List pls = pri.findAll(); + javafx.application.Platform.runLater(() -> { + allPlaylistList.setAll(pls); + if (!allPlaylistList.isEmpty()) { + sourceList.getSelectionModel().selectFirst(); + } + }); + } catch (Exception e) { + logger.error("showLibrary: Failed to load playlists", e); + javafx.application.Platform.runLater(() -> + new Alert(Alert.AlertType.ERROR, "Failed to load playlists").showAndWait() + ); + } }).start(); - // BorderPane är huvudlayouten: Top, Left, Center, Bottom BorderPane root = new BorderPane(); - // --------------------------------------------------------- - // 1. TOPPEN (Knappar, LCD-display, Sökfält) - // --------------------------------------------------------- - HBox topPanel = new HBox(15); // HBox lägger saker på rad horisontellt - topPanel.getStyleClass().add("top-panel"); // CSS-klass för styling + // ----------------------------------------------------------------- + // Top section (controls, LCD display, search field) + // ----------------------------------------------------------------- + HBox topPanel = new HBox(15); + topPanel.getStyleClass().add("top-panel"); topPanel.setPadding(new Insets(10, 15, 10, 15)); topPanel.setAlignment(Pos.CENTER_LEFT); - // Skapa LCD-displayen (den blå rutan med text) StackPane lcdDisplay = createLCDDisplay(); - // Säg åt displayen att växa och ta upp ledig plats i bredd HBox.setHgrow(lcdDisplay, Priority.ALWAYS); - // Sökfältet TextField searchField = new TextField(); searchField.setPromptText("Search..."); searchField.getStyleClass().add("itunes-search"); - // Lyssnare: När texten ändras i sökfältet, kör metoden filterSongs() - searchField.textProperty().addListener((obs, old, newVal) -> filterSongs(newVal)); + // Filter songs whenever the search text changes + searchField.textProperty() + .addListener((obs, old, newVal) -> filterSongs(newVal)); - // Lägg till allt i toppen topPanel.getChildren().addAll( - createRoundButton("⏮"), createRoundButton("▶"), createRoundButton("⏭"), - lcdDisplay, searchField + createRoundButton("⏮"), + createRoundButton("▶"), + createRoundButton("⏭"), + lcdDisplay, + searchField ); - // --------------------------------------------------------- - // 2. VÄNSTER (Spellistorna) - // --------------------------------------------------------- - + // ----------------------------------------------------------------- + // Left section (playlist navigation) + // ----------------------------------------------------------------- sourceList.setItems(allPlaylistList); // Koppla data till listan sourceList.getStyleClass().add("source-list"); sourceList.setPrefWidth(200); @@ -121,7 +168,7 @@ protected void updateItem(Playlist playlist, boolean empty) { super.updateItem(playlist, empty); if (empty || playlist == null) { setText(null); - setContextMenu(null); // Ingen meny på tom rad + setContextMenu(null); } else { setText(playlist.getName()); } @@ -161,60 +208,70 @@ protected void updateItem(Playlist playlist, boolean empty) { return cell; }); - // Lyssnare: Vad händer när man klickar på en spellista i menyn? - sourceList.getSelectionModel().selectedItemProperty().addListener((obs, old, newVal) -> { - if (newVal != null) { - searchField.clear(); // Rensa gammal sökning - // Hämta låtlistan från vår Map baserat på namnet och visa i tabellen - ObservableList songList = FXCollections.observableArrayList(newVal.getSongs().stream().toList()); - songTable.setItems(songList); - } - }); - - sourceList.getSelectionModel().selectFirst(); // Välj första listan ("Bibliotek") som startvärde + // Update song table when a playlist is selected + sourceList.getSelectionModel() + .selectedItemProperty() + .addListener((obs, old, newVal) -> { + if (newVal != null) { + searchField.clear(); + ObservableList songList + = FXCollections.observableArrayList( + newVal.getSongs().stream().toList() + ); + songTable.setItems(songList); + } + }); - // --------------------------------------------------------- - // 3. MITTEN (Låttabellen) - // --------------------------------------------------------- - setupTable(); // Konfigurerar kolumner och beteende för tabellen + // ----------------------------------------------------------------- + // Center section (song table) + // ----------------------------------------------------------------- + setupTable(); - // --------------------------------------------------------- - // 4. BOTTEN (Knappar för att hantera listor) - // --------------------------------------------------------- + // ----------------------------------------------------------------- + // Bottom section (playlist controls) + // ----------------------------------------------------------------- HBox bottomPanel = new HBox(10); bottomPanel.setPadding(new Insets(10)); bottomPanel.getStyleClass().add("bottom-panel"); Button btnAddList = new Button("+"); btnAddList.getStyleClass().add("list-control-button"); + Button btnDeleteList = new Button("-"); btnDeleteList.getStyleClass().add("list-control-button"); + Button btnMoveToPlaylist = new Button("Add song to playlist"); Button btnRemoveSong = new Button("Remove song from playlist"); - // Koppla knapparna till metoder btnAddList.setOnAction(e -> createNewPlaylist()); btnDeleteList.setOnAction(e -> deleteSelectedPlaylist()); btnRemoveSong.setOnAction(e -> removeSelectedSong()); btnMoveToPlaylist.setOnAction(e -> addSelectedSong(btnMoveToPlaylist)); - bottomPanel.getChildren().addAll(btnAddList, btnDeleteList, new Separator(), btnMoveToPlaylist, btnRemoveSong); - - // --------------------------------------------------------- - // SLUTMONTERING - // --------------------------------------------------------- + bottomPanel.getChildren().addAll( + btnAddList, + btnDeleteList, + new Separator(), + btnMoveToPlaylist, + btnRemoveSong); - // SplitPane gör att användaren kan dra i gränsen mellan vänstermeny och tabell + // ----------------------------------------------------------------- + // Final layout assembly + // ----------------------------------------------------------------- SplitPane splitPane = new SplitPane(sourceList, songTable); - splitPane.setDividerPositions(0.25); // Sätt startposition för avdelaren + splitPane.setDividerPositions(0.25); root.setTop(topPanel); root.setCenter(splitPane); root.setBottom(bottomPanel); Scene scene = new Scene(root, 950, 600); - // Ladda CSS-filen (måste ligga i resources-mappen) - scene.getStylesheets().add(getClass().getResource("/ipod_style.css").toExternalForm()); + var cssResource = getClass().getResource("/ipod_style.css"); + if (cssResource != null) { + scene.getStylesheets().add(cssResource.toExternalForm()); + } else { + logger.warn("Stylesheet /ipod_style.css not found"); + } stage.setScene(scene); stage.setTitle("myTunes"); @@ -222,7 +279,9 @@ protected void updateItem(Playlist playlist, boolean empty) { } /** - * Hjälpmetod för att skapa LCD-displayen (bakgrund + text). + * Creates the LCD-style display used in the top panel. + * + * @return a {@link StackPane} containing the LCD display */ private StackPane createLCDDisplay() { StackPane stack = new StackPane(); @@ -231,16 +290,21 @@ private StackPane createLCDDisplay() { VBox textStack = new VBox(2); textStack.setAlignment(Pos.CENTER); + lcdTitle.getStyleClass().add("lcd-title"); lcdArtist.getStyleClass().add("lcd-artist"); textStack.getChildren().addAll(lcdTitle, lcdArtist); stack.getChildren().addAll(bg, textStack); + return stack; } /** - * Hjälpmetod för att skapa en standardiserad knapp. + * Creates a standardized round button with the given icon text. + * + * @param icon the text or symbol to display on the button + * @return a styled {@link Button} */ private Button createRoundButton(String icon) { Button b = new Button(icon); @@ -249,12 +313,11 @@ private Button createRoundButton(String icon) { } /** - * Konfigurerar kolumnerna i tabellen och hur data ska visas. + * Configures the song table columns, selection behavior, and context menus. */ private void setupTable() { - // Skapa kolumner - TableColumn titleCol = new TableColumn<>("Name"); - // Berätta för kolumnen vilket fält i DisplaySong den ska läsa från (name) + TableColumn titleCol = new TableColumn<>("Title"); + titleCol.setCellValueFactory(d -> { Song s = d.getValue(); if (s.getName() != null) { @@ -281,22 +344,21 @@ private void setupTable() { return new SimpleStringProperty("Unknown album"); }); - TableColumn timeCol = new TableColumn<>("Time"); + TableColumn timeCol = new TableColumn<>("Length"); timeCol.setCellValueFactory(d -> { Song s = d.getValue(); if (s.getFormattedLength() != null) { return new SimpleStringProperty(s.getFormattedLength()); } - return new SimpleStringProperty("Unknown time"); + return new SimpleStringProperty("Unknown length"); }); songTable.getColumns().setAll(titleCol, artistCol, albumCol, timeCol); songTable.getStyleClass().add("song-table"); - // Gör så att kolumnerna fyller ut hela bredden - songTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + songTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); - // Lyssnare: När man klickar på en rad i tabellen -> Uppdatera LCD-displayen + // Update LCD display when clicking on a row in a table songTable.getSelectionModel().selectedItemProperty().addListener((obs, old, newVal) -> { if (newVal != null) { lcdTitle.setText(newVal.getName()); @@ -308,7 +370,7 @@ private void setupTable() { } }); - /// NY KOD - Högerklicksfunktion på låtar för att lägga till och ta bort från spellista //// + // Right click function, to add song to playlist and remove song from playlist songTable.setRowFactory(songTableView -> { TableRow row = new TableRow<>(); ContextMenu contextMenu = new ContextMenu(); @@ -326,14 +388,13 @@ private void setupTable() { removeSelectedSong(); }); - // VIKTIGT: Uppdatera när hela ContextMenu visas + // Update when showing ContextMenu contextMenu.setOnShowing(event -> { addSongSubMenu.getItems().clear(); Song selectedSong = row.getItem(); if (selectedSong != null && !allPlaylistList.isEmpty()) { for (Playlist pl : allPlaylistList) { - // Hoppa över biblioteket/huvudlistan om id är 1 if (pl.getId() != null && pl.getId().equals(1L)) continue; MenuItem playListItem = new MenuItem(pl.getName()); @@ -371,32 +432,29 @@ private void setupTable() { row.setContextMenu(contextMenu); } }); - return row; }); } /** - * Filtrerar låtarna i den aktiva listan baserat på söktexten. + * Filters the songs of the currently selected playlist + * based on the provided search text. + * + * @param searchText the text used for filtering */ private void filterSongs(String searchText) { Playlist selectedPlaylist = sourceList.getSelectionModel().getSelectedItem(); if (selectedPlaylist == null) return; - Long currentList = selectedPlaylist.getId(); - // Hämta originaldatan för den valda spellistan - ObservableList masterData = FXCollections.observableArrayList(pri.findById(currentList).getSongs()); + ObservableList masterData = FXCollections.observableArrayList(selectedPlaylist.getSongs()); - // Om sökfältet är tomt, visa allt if (searchText == null || searchText.isEmpty()) { songTable.setItems(masterData); return; } - // Skapa en filtrerad lista som omsluter masterData FilteredList filteredData = new FilteredList<>(masterData, song -> { String filter = searchText.toLowerCase(); - // Returnera true om sökordet finns i namn, artist eller album boolean titleMatch = song.getName() != null && song.getName().toLowerCase().contains(filter); boolean artistMatch = song.getAlbum() != null && song.getAlbum().getArtist() != null && @@ -412,14 +470,13 @@ private void filterSongs(String searchText) { } /** - * Visar en dialogruta för att skapa en ny spellista. + * Displays a dialog allowing the user to create a new playlist. */ private void createNewPlaylist() { TextInputDialog d = new TextInputDialog("New playlist"); - // Här ändrar du fönstrets titel och text - d.setTitle("Create new playlist"); // Ersätter "Bekräftelse" - d.setHeaderText("Enter playlist name"); // Rubriken inuti rutan - d.setContentText("Name:"); // Texten bredvid inmatningsfältet + d.setTitle("Create new playlist"); + d.setHeaderText("Enter playlist name"); + d.setContentText("Name:"); d.showAndWait().ifPresent(name -> { if (!name.trim().isEmpty()) { @@ -431,7 +488,11 @@ private void createNewPlaylist() { } /** - * Ändra namn på vald spellista (men tillåter inte att man ändrar "Bibliotek"). ?? + * Renames the currently selected playlist. + * + *

+ * System playlists such as the main library and favorites cannot be renamed. + *

*/ private void renameSelectedPlaylist() { Playlist sel = sourceList.getSelectionModel().getSelectedItem(); @@ -452,7 +513,7 @@ private void renameSelectedPlaylist() { sel.setName(newName); sourceList.refresh(); } catch (IllegalStateException ex) { - logger.error("renameSelectedPlaylist: failed to rename", ex); + logger.error("renameSelectedPlaylist: failed to rename ", ex); new Alert(Alert.AlertType.ERROR, "Failed to rename: " + ex.getMessage()).showAndWait(); } } @@ -461,49 +522,68 @@ private void renameSelectedPlaylist() { } /** - * Tar bort vald spellista (men tillåter inte att man tar bort "Bibliotek" eller "Favoriter"). + * Deletes the currently selected playlist. + * + *

+ * System playlists (e.g. Library and Favorites) cannot be deleted. + *

*/ private void deleteSelectedPlaylist() { Playlist sel = sourceList.getSelectionModel().getSelectedItem(); if (sel != null && sel.getId() != null && !sel.getId().equals(1L) && !sel.getId().equals(2L)) { - pri.deletePlaylist(sel); - allPlaylistList.remove(sel); - - refresh(); + try { + pri.deletePlaylist(sel); + allPlaylistList.remove(sel); + refresh(); + } catch (Exception ex) { + logger.error("deleteSelectedPlaylist: failed to delete", ex); + new Alert(Alert.AlertType.ERROR, "Failed to delete playlist: " + ex.getMessage()).showAndWait(); + } } } /** - * Tar bort vald låt från den aktiva spellistan (ej från huvudbiblioteket "Bibliotek"). + * Removes the selected song from the currently active playlist. + * + *

+ * Songs cannot be removed directly from the main library. + *

*/ private void removeSelectedSong() { Song sel = songTable.getSelectionModel().getSelectedItem(); Playlist list = sourceList.getSelectionModel().getSelectedItem(); - // Skydd: Man får inte ta bort låtar direkt från biblioteket i denna vy - if (sel != null && list != null && list.getId() != null && !list.getId().equals(1L)) { - pri.removeSong(list, sel); - list.getSongs().remove(sel); - songTable.getItems().remove(sel); - refresh(); + // You cannot remove song from Library + if (sel != null && list != null && list.getId() != null && !list.getId().equals(1L)) { + try { + pri.removeSong(list, sel); + list.getSongs().remove(sel); + songTable.getItems().remove(sel); + refresh(); + } catch (Exception ex) { + logger.error("removeSelectedSong: failed to remove", ex); + new Alert(Alert.AlertType.ERROR, "Failed to remove song: " + ex.getMessage()).showAndWait(); + } } } /** - * Visar en popup-meny för att lägga till vald låt i en annan spellista. + * Displays a context menu allowing the user to add the selected song + * to another playlist. + * + * @param anchor the UI element used as the menu anchor */ private void addSelectedSong(Button anchor) { Song sel = songTable.getSelectionModel().getSelectedItem(); - if (sel == null) return; // Ingen låt vald + if (sel == null) return; ContextMenu menu = new ContextMenu(); for (Playlist pl : allPlaylistList) { if (pl.getId() != null && pl.getId().equals(1L)) - continue; // Man kan inte lägga till i "Bibliotek" (det är källan) + continue; // You cannot add song to Library MenuItem itm = new MenuItem(pl.getName()); itm.setOnAction(e -> { - // Om låten inte redan finns i listan, lägg till den if (!pri.isSongInPlaylist(pl, sel)) { try { pri.addSong(pl, sel); @@ -517,7 +597,7 @@ private void addSelectedSong(Button anchor) { }); menu.getItems().add(itm); } - // Visa menyn vid knappen + var bounds = anchor.localToScreen(anchor.getBoundsInLocal()); menu.show(anchor, bounds.getMinX(), bounds.getMaxY()); } diff --git a/src/main/java/org/example/MyPod.java b/src/main/java/org/example/MyPod.java index 202fe057..f1a5c198 100644 --- a/src/main/java/org/example/MyPod.java +++ b/src/main/java/org/example/MyPod.java @@ -47,87 +47,116 @@ import java.util.List; /** - * Huvudklassen för applikationen "MyPod". - * Denna klass bygger upp GUI:t (simulerar en iPod) och hanterar navigering. + * Main application class for {@code MyPod}. + *

+ * This class is responsible for: + *

    + *
  • Bootstrapping the JavaFX application
  • + *
  • Constructing the iPod-style graphical user interface
  • + *
  • Handling keyboard navigation and menu state
  • + *
  • Coordinating playback of song previews
  • + *
*/ public class MyPod extends Application { - private static final Logger logger = LoggerFactory.getLogger(MyPod.class); - private String currentScreenName = ""; private Playlist currentActivePlaylist = null; - // --- DATA-LAGER --- - // Repositories används för att hämta data från databasen istället för att hårdkoda den. + // ------------------------------------------------------------------------- + // Data layer + // ------------------------------------------------------------------------- + + /** + * Repositories used for song/artist/album/playlist persistence operations. + */ private final SongRepository songRepo = new SongRepositoryImpl(PersistenceManager.getEntityManagerFactory()); private final ArtistRepository artistRepo = new ArtistRepositoryImpl(PersistenceManager.getEntityManagerFactory()); private final AlbumRepository albumRepo = new AlbumRepositoryImpl(PersistenceManager.getEntityManagerFactory()); private final PlaylistRepository playlistRepo = new PlaylistRepositoryImpl(PersistenceManager.getEntityManagerFactory()); + + /** + * Client used to fetch preview data from the iTunes API. + */ private final ItunesApiClient apiClient = new ItunesApiClient(); - // Listor som håller datan vi hämtat från databasen + /** + * Cached data loaded from the database. + */ private List songs; private List artists; private List albums; private List playlists; - // --- MENY-DATA --- - // Huvudmenyns alternativ. "ObservableList" är en speciell lista i JavaFX - // som GUI:t kan "lyssna" på, även om vi här mest använder den som en vanlig lista. + // ------------------------------------------------------------------------- + // Menu data + // ------------------------------------------------------------------------- + + /** + * Entries displayed in the main menu. + *

+ * {@link ObservableList} is used so JavaFX can react to changes if needed. + */ private final ObservableList mainMenu = FXCollections.observableArrayList( "Songs", "Artists", "Albums", "Playlists"); - // En lista med själva Label-objekten som visas på skärmen (för att kunna markera/avmarkera dem) + /** + * Labels currently rendered on screen. + * Used to apply selection highlighting and resolve user actions. + */ private final List menuLabels = new ArrayList<>(); - // --- GUI-TILLSTÅND --- - private int selectedIndex = 0; // Håller koll på vilket menyval som är markerat just nu - private VBox screenContent; // Behållaren för texten/listan inuti "skärmen" - private StackPane myPodScreen; // Själva skärm-containern - private ScrollPane scrollPane; // Gör att vi kan scrolla om listan är lång - private boolean isMainMenu = true; // Flagga för att veta om vi är i huvudmenyn eller en undermeny + // ------------------------------------------------------------------------- + // UI state + // ------------------------------------------------------------------------- - private MediaPlayer mediaPlayer; - private ProgressBar progressBar; - private ProgressBar volumeBar; - private PauseTransition volumeHideTimer; + private int selectedIndex = 0; // Index of the currently selected menu item + private VBox screenContent; // Container holding the current screen content + private StackPane myPodScreen; // Root container representing the device screen + private ScrollPane scrollPane; // Scroll container used for long lists + private boolean isMainMenu = true; // Flag indicating whether the user is currently in the main menu + + private MediaPlayer mediaPlayer; // Media player instance for song previews + private ProgressBar progressBar; // Progress bar showing playback position + private ProgressBar volumeBar; // Overlay progress bar used to display volume changes + private PauseTransition volumeHideTimer;// Timer controlling how long the volume overlay is visible + + // ------------------------------------------------------------------------- + // Application lifecycle + // ------------------------------------------------------------------------- @Override public void start(Stage primaryStage) { - // --- LAYOUT SETUP --- - // BorderPane är bra för att placera saker i Top, Bottom, Center, Left, Right. + // Root layout BorderPane root = new BorderPane(); - root.setPadding(new Insets(20)); // Lite luft runt kanten - root.getStyleClass().add("ipod-body"); // CSS-klass för själva iPod-kroppen + root.setPadding(new Insets(20)); + root.getStyleClass().add("ipod-body"); - // 1. Skapa och placera skärmen högst upp + // Device screen myPodScreen = createMyPodScreen(); root.setTop(myPodScreen); - // 2. Skapa och placera klickhjulet längst ner + // Click wheel StackPane clickWheel = createClickWheel(); root.setBottom(clickWheel); - BorderPane.setMargin(clickWheel, new Insets(30, 0, 0, 0)); // Marginal ovanför hjulet + BorderPane.setMargin(clickWheel, new Insets(30, 0, 0, 0)); + + // --------------------------------------------------------------------- + // Background initialization + // --------------------------------------------------------------------- - // --- BAKGRUNDSLADDNING --- - // Vi använder en Task för att ladda databasen. Detta är kritiskt! - // Om vi laddar databasen direkt i start() fryser hela fönstret tills det är klart. Task initTask = new Task<>() { @Override protected Void call() { - initializeData(); // Detta tunga jobb körs i en separat tråd + initializeData(); return null; } }; - // När datan är laddad och klar: initTask.setOnSucceeded(e -> { - if (isMainMenu) showMainMenu(); // Rita upp menyn nu när vi har data + if (isMainMenu) showMainMenu(); }); - // Om något går fel (t.ex. ingen databasanslutning): initTask.setOnFailed(e -> { - // Platform.runLater måste användas när vi ändrar GUI:t från en annan tråd Platform.runLater(() -> { Label error = new Label("Failed to load data."); error.setStyle("-fx-text-fill: red;"); @@ -135,13 +164,14 @@ protected Void call() { }); }); - // Starta laddningstråden new Thread(initTask).start(); - // --- SCEN OCH CSS --- + // --------------------------------------------------------------------- + // Scene and styling + // --------------------------------------------------------------------- + Scene scene = new Scene(root, 300, 500); try { - // Försök ladda CSS-filen för styling scene.getStylesheets().add(getClass().getResource("/ipod_style.css").toExternalForm()); } catch (Exception e) { logger.info("Start: CSS not found"); @@ -150,9 +180,8 @@ protected Void call() { myPodScreen.setFocusTraversable(true); myPodScreen.setOnMouseClicked(e -> myPodScreen.requestFocus()); - // Koppla tangentbordslyssnare för att kunna styra menyn setupNavigation(scene); - showMainMenu(); // Initiera första vyn (tom tills datan laddats klart) + showMainMenu(); primaryStage.setTitle("myPod"); primaryStage.setScene(scene); @@ -160,8 +189,14 @@ protected Void call() { primaryStage.show(); } + // ------------------------------------------------------------------------- + // UI construction + // ------------------------------------------------------------------------- + /** - * Skapar den visuella skärmen (den "lysande" rutan). + * Creates the visual screen area of the device. + * + * @return configured {@link StackPane} representing the display */ private StackPane createMyPodScreen() { StackPane screenContainer = new StackPane(); @@ -172,22 +207,20 @@ private StackPane createMyPodScreen() { screenContainer.setPrefSize(width, height); screenContainer.setMaxSize(width, height); - // Skapa en "mask" (Rectangle) för att klippa innehållet så hörnen blir rundade Rectangle clip = new Rectangle(width, height); clip.setArcWidth(15); clip.setArcHeight(15); screenContainer.setClip(clip); scrollPane = new ScrollPane(); - screenContent = new VBox(2); // VBox staplar element vertikalt. 2px mellanrum. + screenContent = new VBox(2); screenContent.setAlignment(Pos.TOP_LEFT); screenContent.setPadding(new Insets(10, 5, 10, 5)); - // Konfigurera scrollbaren så den inte syns men fungerar scrollPane.setContent(screenContent); - scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); // Ingen horisontell scroll - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); // Ingen synlig vertikal scroll - scrollPane.setFitToWidth(true); // Innehållet ska fylla bredden + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setFitToWidth(true); scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); screenContainer.getChildren().add(scrollPane); @@ -195,66 +228,73 @@ private StackPane createMyPodScreen() { } /** - * Skapar klickhjulet med knappar. + * Creates the click wheel with menu, navigation and playback controls. */ private StackPane createClickWheel() { StackPane wheel = new StackPane(); wheel.setPrefSize(200, 200); - // Det stora yttre hjulet Circle outerWheel = new Circle(100); outerWheel.getStyleClass().add("outer-wheel"); - // Den lilla knappen i mitten Circle centerButton = new Circle(30); centerButton.getStyleClass().add("center-button"); - // Etiketter för knapparna (MENU, Play, Fram, Bak) Label menu = new Label("MENU"); menu.getStyleClass().add("wheel-text-menu"); - // Om man klickar på ordet MENU med musen går man tillbaka menu.setOnMouseClicked(e -> showMainMenu()); Label ff = new Label("⏭"); ff.getStyleClass().add("wheel-text"); ff.setId("ff-button"); + Label rew = new Label("⏮"); rew.getStyleClass().add("wheel-text"); rew.setId("rew-button"); - // Play/pause-funktion Label playPauseLabel = new Label("▶/⏸"); playPauseLabel.getStyleClass().add("wheel-text-play"); - playPauseLabel.setFocusTraversable(true); - playPauseLabel.setOnMouseClicked(e -> - playPauseFunction()); - + playPauseLabel.setOnMouseClicked(e -> playPauseFunction()); playPauseLabel.setOnKeyPressed(e -> { if (e.getCode() == KeyCode.SPACE) { playPauseFunction(); } }); - wheel.getChildren().addAll(outerWheel, centerButton, menu, ff, rew, playPauseLabel); + wheel.getChildren().addAll( + outerWheel, centerButton, menu, ff, rew, playPauseLabel); + return wheel; } + /** + * Toggles playback state of the active {@link MediaPlayer}. + */ private void playPauseFunction() { if (mediaPlayer != null) { if (mediaPlayer.getStatus() == MediaPlayer.Status.PLAYING) { mediaPlayer.pause(); - } else { mediaPlayer.play(); - } } } + // ------------------------------------------------------------------------- + // Navigation & interaction + // ------------------------------------------------------------------------- + /** - * Hanterar tangentbordsnavigering (Upp, Ner, Enter, Escape). + * Configures keyboard navigation for menus and playback. + *

+ * Supported keys: + *

    + *
  • UP / DOWN – navigate menu or adjust volume
  • + *
  • ENTER – select item
  • + *
  • ESC – navigate back
  • + *
*/ private void setupNavigation(Scene scene) { scene.setOnKeyPressed(event -> { @@ -281,9 +321,7 @@ private void setupNavigation(Scene scene) { showScreen("Artists"); } else if ("PlaylistSongs".equals(currentScreenName)) { showScreen("Playlists"); - } - // ESCAPE fungerar som "Back"-knapp - else { + } else { showMainMenu(); } event.consume(); @@ -291,33 +329,23 @@ private void setupNavigation(Scene scene) { } int totalItems = menuLabels.size(); - if (totalItems == 0) return; // Gör inget om listan är tom + if (totalItems == 0) return; int newIndex = selectedIndex; - // Navigera NER if (event.getCode() == KeyCode.DOWN) { - // Modulo (%) gör att om vi trycker ner på sista elementet, hamnar vi på 0 igen. - newIndex = (selectedIndex + 1) % totalItems; - } - // Navigera UPP - else if (event.getCode() == KeyCode.UP) { - // Matematisk formel för att loopa bakåt (från 0 till sista) - newIndex = (selectedIndex - 1 + totalItems) % totalItems; - } - // Välj (ENTER) - else if (event.getCode() == KeyCode.ENTER) { + newIndex = (selectedIndex + 1) % totalItems; // %, if scrolling past last line, get back to 0 + } else if (event.getCode() == KeyCode.UP) { + newIndex = (selectedIndex - 1 + totalItems) % totalItems; // Formula for looping backwards, from 0 to last + } else if (event.getCode() == KeyCode.ENTER) { if (isMainMenu) { - // Om vi är i huvudmenyn, gå in i en undermeny (t.ex. "Songs") showScreen(mainMenu.get(selectedIndex)); } else { - // Om vi är i en lista, hantera valet (spela låt etc) handleSelection(menuLabels.get(selectedIndex)); } return; } - // Om indexet ändrades, uppdatera grafiken if (newIndex != selectedIndex) { selectedIndex = newIndex; updateMenu(); @@ -325,16 +353,24 @@ else if (event.getCode() == KeyCode.ENTER) { }); } + /** + * Adjusts playback volume and displays the volume overlay. + * + * @param delta positive or negative volume delta + */ private void adjustVolume(double delta) { if (mediaPlayer == null) return; + ensureVolumeBarExists(); double newVolume = Math.max(0, Math.min(1, mediaPlayer.getVolume() + delta)); mediaPlayer.setVolume(newVolume); - volumeBar.setProgress(newVolume); showVolumeOverlay(); } + /** + * Displays the volume overlay temporarily using fade animations. + */ private void showVolumeOverlay() { if (volumeHideTimer != null) { volumeHideTimer.stop(); @@ -354,21 +390,21 @@ private void showVolumeOverlay() { } /** - * Uppdaterar visuellt vilken rad som är vald (lägger till CSS-klass). + * Updates menu selection styling and ensures the selected item is visible. */ private void updateMenu() { for (int i = 0; i < menuLabels.size(); i++) { if (i == selectedIndex) { - menuLabels.get(i).label().getStyleClass().add("selected-item"); // Gör texten markerad - ensureVisible(menuLabels.get(i).label()); // Se till att scrollbaren flyttas så vi ser valet + menuLabels.get(i).label().getStyleClass().add("selected-item"); + ensureVisible(menuLabels.get(i).label()); } else { - menuLabels.get(i).label().getStyleClass().remove("selected-item"); // Ta bort markering + menuLabels.get(i).label().getStyleClass().remove("selected-item"); } } } /** - * Avancerad metod för att automatiskt scrolla ScrollPane till det markerade elementet. + * Automatically scrolls the {@link ScrollPane} so the given label is visible. */ private void ensureVisible(Label node) { Platform.runLater(() -> { @@ -376,31 +412,32 @@ private void ensureVisible(Label node) { double viewportHeight = scrollPane.getViewportBounds().getHeight(); double nodeY = node.getBoundsInParent().getMinY(); - // Om innehållet är högre än skärmen, räkna ut var vi ska scrolla if (contentHeight > viewportHeight) { - // Beräkna positionen (0.0 är toppen, 1.0 är botten) - double scrollTarget = nodeY / (contentHeight - viewportHeight); - // Sätt värdet men tvinga det att vara mellan 0 och 1 - scrollPane.setVvalue(Math.min(1.0, Math.max(0.0, scrollTarget))); + double scrollTarget = nodeY / (contentHeight - viewportHeight); // Calculate position, 0.0 top, 1.0 bottom + scrollPane.setVvalue(Math.min(1.0, Math.max(0.0, scrollTarget))); // Set value between 0.0 and 1.0 } }); } /** - * Visar en undermeny (Songs, Artists etc). + * Displays a secondary screen such as Songs, Artists, Albums or Playlists. + *

+ * This method clears the current screen, resets navigation state and + * populates the view based on the selected screen name. + * + * @param screenName the identifier of the screen to display */ private void showScreen(String screenName) { - screenContent.getChildren().clear(); // Rensa skärmen - menuLabels.clear(); // Rensa listan med menyval - isMainMenu = false; // Vi är inte i huvudmenyn längre - selectedIndex = 0; // Återställ markör till toppen + screenContent.getChildren().clear(); + menuLabels.clear(); + isMainMenu = false; + selectedIndex = 0; // Reset marker to top currentScreenName = screenName; - // Rubrik + Label screenTitle = new Label(screenName); screenTitle.getStyleClass().add("screen-title"); screenContent.getChildren().add(screenTitle); - // Fyll på med rätt data beroende på vad användaren valde switch (screenName) { case "Songs" -> { if (songs != null && !songs.isEmpty()) { @@ -424,16 +461,19 @@ private void showScreen(String screenName) { } else addMenuItem("No playlists found"); } } - updateMenu(); // Uppdatera så första valet är markerat + + updateMenu(); } /** - * Hjälpmetod för att lägga till en rad i listan på skärmen. + * Adds a static text-based menu entry to the current screen. + * + * @param text the text to display in the menu */ private void addMenuItem(String text) { ObjectLabel stringLabel = new ObjectLabel(new Label(text), null); stringLabel.label().getStyleClass().add("menu-item"); - stringLabel.label().setMaxWidth(Double.MAX_VALUE); // Gör att raden fyller hela bredden (snyggare markering) + stringLabel.label().setMaxWidth(Double.MAX_VALUE); if ("Edit Playlists".equals(text)) { stringLabel.label().setStyle("-fx-font-weight: bold; -fx-underline: true;"); @@ -444,19 +484,24 @@ private void addMenuItem(String text) { } /** - * Hjälpmetod för att lägga till en rad i listan på skärmen som pekar på ett object + * Adds a menu entry that represents a domain object. + *

+ * The object's name is displayed and the object itself is stored + * for later selection handling. + * + * @param object the domain object associated with this menu entry */ private void addMenuItem(DBObject object) { ObjectLabel objectLabel = new ObjectLabel(new Label(object.getName()), object); objectLabel.label().getStyleClass().add("menu-item"); - objectLabel.label().setMaxWidth(Double.MAX_VALUE); // Gör att raden fyller hela bredden (snyggare markering) + objectLabel.label().setMaxWidth(Double.MAX_VALUE); menuLabels.add(objectLabel); screenContent.getChildren().add(objectLabel.label()); } /** - * Visar huvudmenyn igen. + * Displays the main menu and resets all navigation state. */ private void showMainMenu() { screenContent.getChildren().clear(); @@ -472,15 +517,18 @@ private void showMainMenu() { for (String item : mainMenu) { addMenuItem(item); } - updateMenu(); + updateMenu(); } /** - * Vad som händer när man trycker Enter på en låt/artist. + * Handles user selection when the ENTER key is pressed. + *

+ * Behavior depends on the currently active screen and the selected item. + * + * @param selection the selected menu entry */ private void handleSelection(ObjectLabel selection) { - // Här kan du lägga till logik för att spela låten eller öppna albumet System.out.println("User selected: " + selection.getText()); if ("Artists".equals(currentScreenName)) { @@ -494,16 +542,13 @@ private void handleSelection(ObjectLabel selection) { openMusicPlayer(); return; } - if (selection.object() == null) { return; } - Playlist selectedPlaylist = playlists.stream() .filter(p -> p.getId() .equals(selection.object().getId())) .findFirst().orElse(null); - if (selectedPlaylist != null) { openPlaylist(selectedPlaylist); } @@ -515,6 +560,11 @@ private void handleSelection(ObjectLabel selection) { } } + /** + * Opens a playlist and displays its contained songs. + * + * @param p the playlist to open + */ private void openPlaylist(Playlist p) { Playlist updatedPlaylist = playlistRepo.findById(p.getId()); @@ -542,15 +592,16 @@ private void openPlaylist(Playlist p) { } else { addMenuItem("No songs found"); } - updateMenu(); + updateMenu(); } /** - * Öppnar det externa fönstret "ItunesPlayList". + * Opens the external playlist management window. + *

+ * When playlists are modified, the current view is refreshed accordingly. */ private void openMusicPlayer() { - if (this.playlists == null) { this.playlists = new ArrayList<>(); } @@ -582,11 +633,15 @@ private void openMusicPlayer() { itunesPlayList.showLibrary(); } + /** + * Displays all albums belonging to the selected artist. + * + * @param selection the selected artist entry + */ private void showArtistAlbums(ObjectLabel selection) { screenContent.getChildren().clear(); menuLabels.clear(); selectedIndex = 0; - currentScreenName = "ArtistAlbums"; Label titleLabel = new Label(selection.getText()); @@ -611,12 +666,15 @@ private void showArtistAlbums(ObjectLabel selection) { updateMenu(); } - + /** + * Displays all songs belonging to the selected album. + * + * @param selection the selected album entry + */ private void showAlbumSongs(ObjectLabel selection) { screenContent.getChildren().clear(); menuLabels.clear(); selectedIndex = 0; - currentScreenName = "AlbumSongs"; Label titleLabel = new Label(selection.getText()); @@ -641,28 +699,30 @@ private void showAlbumSongs(ObjectLabel selection) { updateMenu(); } - + /** + * Displays the "Now Playing" screen and starts playback of a song preview. + * + * @param selection the selected song entry + */ private void showNowPlaying(ObjectLabel selection) { screenContent.getChildren().clear(); menuLabels.clear(); selectedIndex = 0; currentScreenName = "NowPlaying"; - Song currentSong = null; - if (songs != null && selection.object() != null) { - currentSong = songs.stream() - .filter(s -> s.getId().equals(selection.object().getId())) - .findFirst() - .orElse(null); + if (selection.object() == null) { + return; } - // Skapa elementen och tilldela klasser + Song currentSong = (Song) selection.object(); + + // Header Label header = new Label("▶ NOW PLAYING"); header.getStyleClass().add("now-playing-header"); + // Album art ImageView albumArtView = new ImageView(); - - if (currentSong != null && currentSong.getAlbum() != null) { + if (currentSong.getAlbum() != null) { Image cover = currentSong.getAlbum().getCoverImage(); if (cover != null) { albumArtView.setImage(cover); @@ -679,12 +739,13 @@ private void showNowPlaying(ObjectLabel selection) { -fx-background-color: white; """); - Label titleLabel = new Label(selection.getText()); + // Song title + Label titleLabel = new Label(currentSong.getName()); titleLabel.getStyleClass().add("now-playing-title"); titleLabel.setWrapText(true); String artistName; - if (currentSong != null && currentSong.getAlbum() != null && currentSong.getAlbum().getArtist() != null) { + if (currentSong.getAlbum() != null && currentSong.getAlbum().getArtist() != null) { artistName = currentSong.getAlbum().getArtist().getName(); } else { artistName = "Unknown Artist"; @@ -693,7 +754,7 @@ private void showNowPlaying(ObjectLabel selection) { artistLabel.getStyleClass().add("now-playing-artist"); String albumName; - if (currentSong != null && currentSong.getAlbum() != null) { + if (currentSong.getAlbum() != null) { albumName = currentSong.getAlbum().getName(); } else { albumName = "Unknown Album"; @@ -701,10 +762,11 @@ private void showNowPlaying(ObjectLabel selection) { Label albumLabel = new Label(albumName); albumLabel.getStyleClass().add("now-playing-album"); + // Progress bar progressBar = new ProgressBar(0); progressBar.getStyleClass().add("ipod-progress-bar"); - // --- Volume overlay (positioned on top of progress bar) --- + // Volume overlay (positioned on top of progress bar) ensureVolumeBarExists(); volumeBar.setOpacity(0); // start hidden @@ -712,22 +774,26 @@ private void showNowPlaying(ObjectLabel selection) { StackPane progressStack = new StackPane(progressBar, volumeBar); progressStack.setAlignment(Pos.CENTER); - // Layout-behållaren VBox layout = new VBox(3); layout.getStyleClass().add("now-playing-container"); - layout.getChildren().addAll(header, albumArtView, titleLabel, artistLabel, albumLabel, progressStack); layout.setAlignment(Pos.CENTER); + layout.getChildren().addAll(header, albumArtView, titleLabel, artistLabel, albumLabel, progressStack); screenContent.getChildren().add(layout); - if (currentSong != null) { - String previewUrl = currentSong.getPreviewUrl(); - if (previewUrl != null && !previewUrl.isBlank()) { - playPreview(previewUrl); - } + // Play preview + String previewUrl = currentSong.getPreviewUrl(); + if (previewUrl != null && !previewUrl.isBlank()) { + playPreview(previewUrl); } } + /** + * Plays a song preview from the given URL and binds playback progress + * to the progress bar. + * + * @param url preview stream URL + */ private void playPreview(String url) { try { if (mediaPlayer != null) { @@ -763,42 +829,52 @@ private void playPreview(String url) { mediaPlayer.play(); } catch (Exception e) { - logger.error("playPreview: Could not play preview:", e); + logger.error("playPreview: Could not play preview: ", e); } } + /** + * Lazily creates the volume overlay progress bar if it does not exist. + */ private void ensureVolumeBarExists() { if (volumeBar == null) { - volumeBar = new ProgressBar(0.5); // standard volym 50% + volumeBar = new ProgressBar(0.5); // Standard volume set to 50% volumeBar.getStyleClass().add("ipod-volume-bar"); - volumeBar.setOpacity(0); // hidden by default + volumeBar.setOpacity(0); // Hidden by default } } /** - * Initierar databasen och hämtar all data. - * OBS: Denna körs i en bakgrundstråd (via Task i start-metoden). + * Initializes the database and loads all required data into memory. + *

+ * This method is executed on a background thread. */ private void initializeData() { try { - EntityManagerFactory emf = PersistenceManager.getEntityManagerFactory(); DatabaseInitializer initializer = new DatabaseInitializer(apiClient, songRepo, albumRepo, artistRepo, playlistRepo); - initializer.init(); // Fyll databasen om den är tom + initializer.init(); - // Hämta data till minnet this.songs = songRepo.findAll(); this.artists = artistRepo.findAll(); this.albums = albumRepo.findAll(); this.playlists = playlistRepo.findAll(); } catch (Exception e) { - logger.error("initializeData: Failed to load data", e); + logger.error("initializeData: Failed to load data ", e); } } + /** + * Wrapper record binding a UI label to an optional domain object. + *

+ * Used to distinguish static menu items from selectable entities. + */ private record ObjectLabel( Label label, - DBObject object) { // object is null for static menu items like "Edit Playlists" + DBObject object) { // Object is null for static menu items like "Edit Playlists" + /** + * @return the text displayed by this menu item + */ public String getText() { return label.getText(); } diff --git a/src/main/java/org/example/PersistenceManager.java b/src/main/java/org/example/PersistenceManager.java index 783cdcc5..6439c413 100644 --- a/src/main/java/org/example/PersistenceManager.java +++ b/src/main/java/org/example/PersistenceManager.java @@ -4,6 +4,15 @@ import java.util.Map; +/** + * Central access point for the application's {@link EntityManagerFactory}. + * + *

This class initializes a single, shared {@link EntityManagerFactory} + * instance using {@link EntityManagerFactoryProvider} and exposes it + * for use throughout the application.

+ * + *

The factory is automatically closed when the JVM shuts down.

+ */ public class PersistenceManager { private static final EntityManagerFactory emf = EntityManagerFactoryProvider.create( @@ -21,6 +30,11 @@ public class PersistenceManager { Runtime.getRuntime().addShutdownHook(new Thread(emf::close)); } + /** + * Returns the shared {@link EntityManagerFactory} instance. + * + * @return application-wide {@link EntityManagerFactory} + */ public static EntityManagerFactory getEntityManagerFactory() { return emf; } diff --git a/src/main/java/org/example/entity/Album.java b/src/main/java/org/example/entity/Album.java index afd135f7..7986aefd 100644 --- a/src/main/java/org/example/entity/Album.java +++ b/src/main/java/org/example/entity/Album.java @@ -16,6 +16,18 @@ import java.util.List; import java.util.Objects; +/** + * JPA entity representing an album. + * + *

An {@code Album} is a persistent JPA entity that belongs to an + * {@link Artist} and contains one or more {@link Song} entities.

+ * + *

It also stores optional album artwork as a binary large object (BLOB), + * which is converted to a JavaFX {@link Image} when displayed in the UI.

+ * + *

Album instances are typically created from iTunes API data via + * {@link #fromDTO(ItunesDTO, Artist)}.

+ */ @Entity public class Album implements DBObject { @@ -55,15 +67,25 @@ public Album(Long albumId, String name, String genre, int year, Long trackCount, this.cover = cover; } + /** + * Creates an {@code Album} entity from an iTunes API DTO. + * + *

This factory method extracts album-related data and attempts + * to download and persist album artwork. If artwork cannot be loaded, + * {@code null} is stored and a default image is used by the UI.

+ * + * @param dto source DTO from the iTunes API + * @param artist associated artist entity + * @return a new {@code Album} instance + * @throws IllegalArgumentException if required DTO fields are missing + */ public static Album fromDTO(ItunesDTO dto, Artist artist) { if (dto.collectionId() == null || dto.collectionName() == null) { throw new IllegalArgumentException("Required fields (albumId, albumName) cannot be null"); } - //Try getting cover from url first, if null go for backup image in resources - //Backup might be unnecessary here, better to store as null and load default in ui? + // Try to load album cover from URL. If unavailable, store null and let UI handle fallback image. byte[] cover = generateAlbumCover(dto.artworkUrl100()); - //todo do this async? return new Album(dto.collectionId(), dto.collectionName(), dto.primaryGenreName(), dto.releaseYear(), dto.trackCount(), cover, artist); } @@ -128,6 +150,14 @@ public byte[] getCover() { return cover; } + /** + * Returns the album cover as a JavaFX {@link Image}. + * + *

If no cover is stored or if decoding fails, a default placeholder + * image bundled with the application is returned.

+ * + * @return album cover image or a default image if unavailable + */ public Image getCoverImage() { byte[] bytes = getCover(); if (bytes == null || bytes.length == 0) return loadDefaultImage(); @@ -145,10 +175,10 @@ public void setCover(byte[] cover) { } /** - * generate and returns byte array with cover art + * Downloads album artwork from the given URL and converts it to a byte array. * - * @param url url pointing to desired cover - * @return a byte array of the desired cover, or null if the URL image cannot be loaded + * @param url URL pointing to the album artwork + * @return image data as byte array, or {@code null} if loading fails */ public static byte[] generateAlbumCover(URL url) { BufferedImage bi = loadUrlImage(url); @@ -160,10 +190,10 @@ public static byte[] generateAlbumCover(URL url) { } /** - * converts image to byte array to be stored as BLOB + * Converts a buffered image into a JPEG byte array suitable for BLOB storage. * - * @param bi buffered jpg image - * @return image converted to byte array + * @param bi buffered image + * @return image encoded as byte array, or {@code null} on failure */ public static byte[] imageToBytes(BufferedImage bi) { if (bi == null) return null; @@ -178,9 +208,10 @@ public static byte[] imageToBytes(BufferedImage bi) { } /** + * Loads an image from a remote URL. * - * @param url url pointing to desired cover - * @return bufferedImage of desired cover or null if not available + * @param url URL pointing to an image resource + * @return loaded {@link BufferedImage} or {@code null} if unavailable */ public static BufferedImage loadUrlImage(URL url) { if (url == null) return null; @@ -201,8 +232,9 @@ public static BufferedImage loadUrlImage(URL url) { } /** + * Loads the default album artwork bundled with the application. * - * @return default cover art from resources + * @return default {@link Image}, or {@code null} if the resource cannot be loaded */ public static Image loadDefaultImage() { try (InputStream is = Album.class.getResourceAsStream("/itunescover.jpg")) { diff --git a/src/main/java/org/example/entity/Artist.java b/src/main/java/org/example/entity/Artist.java index f1b03b45..25690523 100644 --- a/src/main/java/org/example/entity/Artist.java +++ b/src/main/java/org/example/entity/Artist.java @@ -8,6 +8,15 @@ import java.util.List; import java.util.Objects; +/** + * JPA entity representing a musical artist. + * + *

An {@code Artist} is the top-level domain object in the music model and + * owns one or more {@link Album} entities. Artists are typically created + * from external data sources (e.g. iTunes API) and persisted using JPA.

+ * + *

Entity identity is based solely on the database identifier.

+ */ @Entity public class Artist implements DBObject { @@ -36,6 +45,13 @@ public Artist(Long artistId, String name, String country) { this.country = country; } + /** + * Factory method for creating an {@code Artist} from an iTunes DTO. + * + * @param dto source DTO + * @return new {@code Artist} instance + * @throws IllegalArgumentException if required DTO fields are missing + */ public static Artist fromDTO(ItunesDTO dto) { if (dto.artistId() == null || dto.artistName() == null) { throw new IllegalArgumentException("Required fields (artistId, artistName) cannot be null"); diff --git a/src/main/java/org/example/entity/DBObject.java b/src/main/java/org/example/entity/DBObject.java index eda1c151..aa1789c0 100644 --- a/src/main/java/org/example/entity/DBObject.java +++ b/src/main/java/org/example/entity/DBObject.java @@ -1,7 +1,6 @@ package org.example.entity; public interface DBObject { - Long getId(); void setId(Long id); diff --git a/src/main/java/org/example/entity/LogEntry.java b/src/main/java/org/example/entity/LogEntry.java index 091b4468..2db6ce08 100644 --- a/src/main/java/org/example/entity/LogEntry.java +++ b/src/main/java/org/example/entity/LogEntry.java @@ -24,7 +24,6 @@ public class LogEntry { @Column(name = "error_details", columnDefinition = "TEXT") private String errorDetails; - @Column(name = "\"timestamp\"") private LocalDateTime timestamp; diff --git a/src/main/java/org/example/entity/Playlist.java b/src/main/java/org/example/entity/Playlist.java index 2617dd02..d1101c90 100644 --- a/src/main/java/org/example/entity/Playlist.java +++ b/src/main/java/org/example/entity/Playlist.java @@ -7,6 +7,15 @@ import java.util.Objects; import java.util.Set; +/** + * JPA entity representing a user-defined playlist. + * + *

A {@code Playlist} groups multiple {@link Song} entities using a + * many-to-many relationship. Playlists are mutable and can have songs + * added or removed dynamically.

+ * + *

Entity identity is based solely on the generated database identifier.

+ */ @Entity public class Playlist implements DBObject { diff --git a/src/main/java/org/example/entity/Song.java b/src/main/java/org/example/entity/Song.java index 06da1f40..73853bca 100644 --- a/src/main/java/org/example/entity/Song.java +++ b/src/main/java/org/example/entity/Song.java @@ -8,6 +8,17 @@ import java.util.Objects; import java.util.Set; +/** + * JPA entity representing an individual song or track. + * + *

A {@code Song} belongs to a single {@link Album} and may appear in + * zero or more {@link Playlist} entities.

+ * + *

Songs are typically created from external metadata sources + * such as the iTunes API.

+ * + *

Entity identity is based solely on the song identifier.

+ */ @Entity public class Song implements DBObject { @@ -40,6 +51,14 @@ public Song(Long songId, String title, Long length, String previewUrl, Album alb this.album = album; } + /** + * Factory method for creating a {@code Song} from an iTunes DTO. + * + * @param dto source DTO + * @param album album the song belongs to + * @return new {@code Song} instance + * @throws IllegalArgumentException if required DTO fields are missing + */ public static Song fromDTO(ItunesDTO dto, Album album) { if (dto.trackId() == null || dto.trackName() == null) { throw new IllegalArgumentException("Required fields (trackId, trackName) cannot be null"); @@ -47,6 +66,11 @@ public static Song fromDTO(ItunesDTO dto, Album album) { return new Song(dto.trackId(), dto.trackName(), dto.trackTimeMillis(), dto.previewUrl(), album); } + /** + * Returns the song length formatted as {@code mm:ss}. + * + * @return formatted duration string + */ public String getFormattedLength() { if (length == null) return "0:00"; diff --git a/src/main/java/org/example/logging/LoggingConnection.java b/src/main/java/org/example/logging/LoggingConnection.java index 5f937cf4..46c8bee3 100644 --- a/src/main/java/org/example/logging/LoggingConnection.java +++ b/src/main/java/org/example/logging/LoggingConnection.java @@ -7,7 +7,6 @@ import java.sql.SQLException; public class LoggingConnection { - private static HikariConfig config = new HikariConfig(); private static HikariDataSource ds; diff --git a/src/main/java/org/example/repo/AlbumRepository.java b/src/main/java/org/example/repo/AlbumRepository.java index de0f5863..47321d4d 100644 --- a/src/main/java/org/example/repo/AlbumRepository.java +++ b/src/main/java/org/example/repo/AlbumRepository.java @@ -5,6 +5,16 @@ import java.util.List; +/** + * Repository interface for managing {@link Album} entities. + * + *

This interface defines the contract for album-related + * persistence operations, independent of the underlying + * persistence technology.

+ * + *

Implementations are responsible for handling database + * access and entity lifecycle management.

+ */ public interface AlbumRepository { boolean existsByUniqueId(Album album); @@ -17,6 +27,4 @@ public interface AlbumRepository { List findByArtist(Artist artist); - List findByGenre(String genre); - } diff --git a/src/main/java/org/example/repo/AlbumRepositoryImpl.java b/src/main/java/org/example/repo/AlbumRepositoryImpl.java index 556dc2ca..5c83e19a 100644 --- a/src/main/java/org/example/repo/AlbumRepositoryImpl.java +++ b/src/main/java/org/example/repo/AlbumRepositoryImpl.java @@ -6,14 +6,37 @@ import java.util.List; +/** + * JPA-based implementation of {@link AlbumRepository}. + * + *

+ * Provides persistence operations for {@link Album} entities, including + * existence checks, basic CRUD functionality, and simple query methods. + *

+ * + *

+ * All operations are executed using an {@link EntityManagerFactory}, with + * transactions managed internally where required. + *

+ */ public class AlbumRepositoryImpl implements AlbumRepository { - private final EntityManagerFactory emf; + /** + * Creates a new {@code AlbumRepositoryImpl}. + * + * @param emf the {@link EntityManagerFactory} used to create entity managers + */ public AlbumRepositoryImpl(EntityManagerFactory emf) { this.emf = emf; } + /** + * Checks whether an album with the same unique identifier already exists. + * + * @param album the album whose identifier should be checked + * @return {@code true} if an album with the given ID exists, otherwise {@code false} + */ @Override public boolean existsByUniqueId(Album album) { return emf.callInTransaction(em -> @@ -23,6 +46,11 @@ public boolean existsByUniqueId(Album album) { ); } + /** + * Returns the total number of albums stored in the database. + * + * @return the album count + */ @Override public Long count() { return emf.callInTransaction(em -> @@ -30,11 +58,21 @@ public Long count() { .getSingleResult()); } + /** + * Persists a new album. + * + * @param album the album to persist + */ @Override public void save(Album album) { emf.runInTransaction(em -> em.persist(album)); } + /** + * Retrieves all albums. + * + * @return a list of all albums + */ @Override public List findAll() { return emf.callInTransaction(em -> @@ -42,6 +80,12 @@ public List findAll() { .getResultList()); } + /** + * Retrieves all albums by the given artist. + * + * @param artist the artist whose albums should be retrieved + * @return a list of albums associated with the given artist + */ @Override public List findByArtist(Artist artist) { return emf.callInTransaction(em -> @@ -50,13 +94,4 @@ public List findByArtist(Artist artist) { .getResultList() ); } - - @Override - public List findByGenre(String genre) { - return emf.callInTransaction(em -> - em.createQuery("select a from Album a where a.genre = :genre", Album.class) - .setParameter("genre", genre) - .getResultList() - ); - } } diff --git a/src/main/java/org/example/repo/ArtistRepository.java b/src/main/java/org/example/repo/ArtistRepository.java index 9f1c165c..82d35e06 100644 --- a/src/main/java/org/example/repo/ArtistRepository.java +++ b/src/main/java/org/example/repo/ArtistRepository.java @@ -4,6 +4,16 @@ import java.util.List; +/** + * Repository interface for managing {@link Artist} entities. + * + *

This interface defines the contract for artist-related + * persistence operations, independent of the underlying + * persistence technology.

+ * + *

Implementations are responsible for handling database + * access and entity lifecycle management.

+ */ public interface ArtistRepository { boolean existsByUniqueId(Artist artist); diff --git a/src/main/java/org/example/repo/ArtistRepositoryImpl.java b/src/main/java/org/example/repo/ArtistRepositoryImpl.java index 93cb59f6..08728ad3 100644 --- a/src/main/java/org/example/repo/ArtistRepositoryImpl.java +++ b/src/main/java/org/example/repo/ArtistRepositoryImpl.java @@ -5,14 +5,32 @@ import java.util.List; +/** + * JPA-based implementation of {@link ArtistRepository}. + * + *

+ * Handles persistence and retrieval of {@link Artist} entities, providing + * basic CRUD operations and simple aggregate queries. + *

+ */ public class ArtistRepositoryImpl implements ArtistRepository { - private final EntityManagerFactory emf; + /** + * Creates a new {@code ArtistRepositoryImpl}. + * + * @param emf the {@link EntityManagerFactory} used to create entity managers + */ public ArtistRepositoryImpl(EntityManagerFactory emf) { this.emf = emf; } + /** + * Checks whether an artist with the same unique identifier already exists. + * + * @param artist the artist whose identifier should be checked + * @return {@code true} if an artist with the given ID exists, otherwise {@code false} + */ @Override public boolean existsByUniqueId(Artist artist) { return emf.callInTransaction(em -> @@ -22,6 +40,11 @@ public boolean existsByUniqueId(Artist artist) { ); } + /** + * Returns the total number of artists stored in the database. + * + * @return the artist count + */ @Override public Long count() { return emf.callInTransaction(em -> @@ -29,11 +52,21 @@ public Long count() { .getSingleResult()); } + /** + * Persists a new artist. + * + * @param artist the artist to persist + */ @Override public void save(Artist artist) { emf.runInTransaction(em -> em.persist(artist)); } + /** + * Retrieves all artists. + * + * @return a list of all artists + */ @Override public List findAll() { return emf.callInTransaction(em -> diff --git a/src/main/java/org/example/repo/PlaylistRepository.java b/src/main/java/org/example/repo/PlaylistRepository.java index eff71b5d..b653987d 100644 --- a/src/main/java/org/example/repo/PlaylistRepository.java +++ b/src/main/java/org/example/repo/PlaylistRepository.java @@ -1,5 +1,6 @@ package org.example.repo; +import org.example.entity.Album; import org.example.entity.Playlist; import org.example.entity.Song; @@ -7,6 +8,16 @@ import java.util.List; import java.util.Set; +/** + * Repository interface for managing {@link Playlist} entities. + * + *

This interface defines the contract for playlist-related + * persistence operations, independent of the underlying + * persistence technology.

+ * + *

Implementations are responsible for handling database + * access and entity lifecycle management.

+ */ public interface PlaylistRepository { boolean existsByUniqueId(Long id); @@ -15,8 +26,6 @@ public interface PlaylistRepository { Playlist findById(Long id); - Set findSongsInPlaylist(Playlist playlist); - boolean isSongInPlaylist(Playlist playlist, Song song); Playlist createPlaylist(String name); diff --git a/src/main/java/org/example/repo/PlaylistRepositoryImpl.java b/src/main/java/org/example/repo/PlaylistRepositoryImpl.java index 6b79767f..637575e6 100644 --- a/src/main/java/org/example/repo/PlaylistRepositoryImpl.java +++ b/src/main/java/org/example/repo/PlaylistRepositoryImpl.java @@ -12,21 +12,45 @@ import java.util.List; import java.util.Set; +/** + * JPA-based implementation of {@link PlaylistRepository}. + * + *

+ * This repository is responsible for all persistence operations related to + * {@link Playlist} entities, including creation, retrieval, modification, + * and deletion, as well as managing associations between playlists and songs. + *

+ * + *

+ * All write operations are executed within transactions. Read operations + * use dedicated {@code EntityManager} instances to ensure proper resource handling. + *

+ */ public class PlaylistRepositoryImpl implements PlaylistRepository { - private static final Logger logger = LoggerFactory.getLogger(PlaylistRepositoryImpl.class); - private final EntityManagerFactory emf; + /** + * Creates a new {@code PlaylistRepositoryImpl}. + * + * @param emf the {@link EntityManagerFactory} used to create entity managers + */ public PlaylistRepositoryImpl(EntityManagerFactory emf) { this.emf = emf; } + /** + * Checks whether a playlist exists with the given unique identifier. + * + * @param id the playlist ID + * @return {@code true} if a playlist with the given ID exists, otherwise {@code false} + * @throws IllegalArgumentException if {@code id} is {@code null} + */ @Override public boolean existsByUniqueId(Long id) { if (id == null) { logger.error("existsByUniqueId: id is null"); - throw new IllegalArgumentException("Playlist id cannot be null"); + throw new IllegalArgumentException("Playlist id can not be null"); } try (var em = emf.createEntityManager()) { return em.createQuery("select count(pl) from Playlist pl where pl.id = :playlistId", Long.class) @@ -35,6 +59,15 @@ public boolean existsByUniqueId(Long id) { } } + /** + * Retrieves all playlists with their associated songs, albums, and artists eagerly fetched. + * + *

+ * {@code DISTINCT} is used to avoid duplicate playlists caused by join fetching. + *

+ * + * @return a list of all playlists + */ @Override public List findAll() { try (var em = emf.createEntityManager()) { @@ -48,11 +81,20 @@ public List findAll() { } } + /** + * Retrieves a playlist by its identifier, including all associated songs, + * albums, and artists. + * + * @param id the playlist ID + * @return the matching {@link Playlist} + * @throws IllegalArgumentException if {@code id} is {@code null} + * @throws EntityNotFoundException if no playlist with the given ID exists + */ @Override public Playlist findById(Long id) { if (id == null) { logger.error("findById: id is null"); - throw new IllegalArgumentException("Playlist id cannot be null"); + throw new IllegalArgumentException("Playlist id can not be null"); } try (var em = emf.createEntityManager()) { try { @@ -73,18 +115,14 @@ public Playlist findById(Long id) { } } - @Override - public Set findSongsInPlaylist(Playlist playlist) { - if (playlist == null) { - logger.error("findSongsInPlaylist: playlist is null"); - throw new IllegalArgumentException("playlist cannot be null"); - } - return emf.callInTransaction(em -> { - Playlist managed = em.merge(playlist); - return managed.getSongs(); - }); - } - + /** + * Checks whether a given song is part of a specific playlist. + * + * @param playlist the playlist to check + * @param song the song to look for + * @return {@code true} if the song is contained in the playlist, otherwise {@code false} + * @throws IllegalArgumentException if {@code playlist} or {@code song} is {@code null} + */ @Override public boolean isSongInPlaylist(Playlist playlist, Song song) { if (playlist == null || song == null) { @@ -100,6 +138,13 @@ public boolean isSongInPlaylist(Playlist playlist, Song song) { } } + /** + * Creates and persists a new playlist with the given name. + * + * @param name the name of the new playlist + * @return the persisted {@link Playlist} + * @throws IllegalArgumentException if {@code name} is {@code null} or blank + */ @Override public Playlist createPlaylist(String name) { if (name == null || name.trim().isEmpty()) { @@ -111,6 +156,13 @@ public Playlist createPlaylist(String name) { return playlist; } + /** + * Renames an existing playlist. + * + * @param playlist the playlist to rename + * @param newName the new name + * @throws IllegalArgumentException if arguments are invalid or playlist does not exist + */ @Override public void renamePlaylist(Playlist playlist, String newName) { if (playlist == null || newName == null || newName.trim().isEmpty()) { @@ -127,6 +179,12 @@ public void renamePlaylist(Playlist playlist, String newName) { }); } + /** + * Deletes the given playlist. + * + * @param playlist the playlist to delete + * @throws IllegalArgumentException if {@code playlist} is {@code null} + */ @Override public void deletePlaylist(Playlist playlist) { if (playlist == null) { @@ -139,6 +197,13 @@ public void deletePlaylist(Playlist playlist) { }); } + /** + * Adds a single song to a playlist. + * + * @param playlist the target playlist + * @param song the song to add + * @throws IllegalArgumentException if playlist or song does not exist + */ @Override public void addSong(Playlist playlist, Song song) { if (playlist == null || song == null) { @@ -162,6 +227,13 @@ public void addSong(Playlist playlist, Song song) { }); } + /** + * Adds multiple songs to a playlist. + * + * @param playlist the target playlist + * @param songs the songs to add + * @throws IllegalArgumentException if playlist or songs are invalid + */ @Override public void addSongs(Playlist playlist, Collection songs) { if (playlist == null || songs == null) { @@ -187,6 +259,13 @@ public void addSongs(Playlist playlist, Collection songs) { }); } + /** + * Removes a song from a playlist. + * + * @param playlist the playlist to modify + * @param song the song to remove + * @throws IllegalArgumentException if playlist or song does not exist + */ @Override public void removeSong(Playlist playlist, Song song) { if (playlist == null || song == null) { diff --git a/src/main/java/org/example/repo/SongRepository.java b/src/main/java/org/example/repo/SongRepository.java index 0cfa0e68..bfe40219 100644 --- a/src/main/java/org/example/repo/SongRepository.java +++ b/src/main/java/org/example/repo/SongRepository.java @@ -6,6 +6,16 @@ import java.util.List; +/** + * Repository interface for managing {@link Song} entities. + * + *

This interface defines the contract for song-related + * persistence operations, independent of the underlying + * persistence technology.

+ * + *

Implementations are responsible for handling database + * access and entity lifecycle management.

+ */ public interface SongRepository { boolean existsByUniqueId(Song song); @@ -20,5 +30,4 @@ public interface SongRepository { List findByAlbum(Album album); - List findByGenre(String genre); } diff --git a/src/main/java/org/example/repo/SongRepositoryImpl.java b/src/main/java/org/example/repo/SongRepositoryImpl.java index 5ee7900e..d223f7fc 100644 --- a/src/main/java/org/example/repo/SongRepositoryImpl.java +++ b/src/main/java/org/example/repo/SongRepositoryImpl.java @@ -10,16 +10,37 @@ import java.util.ArrayList; import java.util.List; +/** + * JPA-based implementation of {@link SongRepository}. + * + *

+ * Provides persistence and query operations for {@link Song} entities, + * including lookups by artist and album. + *

+ * + *

+ * Certain queries eagerly fetch related album and artist entities to + * avoid lazy loading issues in the presentation layer. + *

+ */ public class SongRepositoryImpl implements SongRepository { - private static final Logger logger = LoggerFactory.getLogger(SongRepositoryImpl.class); - private final EntityManagerFactory emf; + /** + * Creates a new {@code SongRepositoryImpl}. + * + * @param emf the {@link EntityManagerFactory} used to create entity managers + */ public SongRepositoryImpl(EntityManagerFactory emf) { this.emf = emf; } + /** + * Returns the total number of songs stored in the database. + * + * @return the song count + */ @Override public Long count() { try (var em = emf.createEntityManager()) { @@ -28,6 +49,12 @@ public Long count() { } } + /** + * Checks whether a song with the same unique identifier already exists. + * + * @param song the song whose identifier should be checked + * @return {@code true} if a song with the given ID exists, otherwise {@code false} + */ @Override public boolean existsByUniqueId(Song song) { try (var em = emf.createEntityManager()) { @@ -37,11 +64,21 @@ public boolean existsByUniqueId(Song song) { } } + /** + * Persists a new song. + * + * @param song the song to persist + */ @Override public void save(Song song) { emf.runInTransaction(em -> em.persist(song)); } + /** + * Retrieves all songs. + * + * @return a list of all songs + */ @Override public List findAll() { return emf.callInTransaction(em -> @@ -49,6 +86,16 @@ public List findAll() { .getResultList()); } + /** + * Retrieves all songs by the given artist. + * + *

+ * Album and artist associations are eagerly fetched. + *

+ * + * @param artist the artist whose songs should be retrieved + * @return a list of songs, or an empty list if {@code artist} is {@code null} + */ @Override public List findByArtist(Artist artist) { if (artist == null) { @@ -71,6 +118,12 @@ public List findByArtist(Artist artist) { .getResultList()); } + /** + * Retrieves all songs from the given album. + * + * @param album the album whose songs should be retrieved + * @return a list of songs, or an empty list if {@code album} is {@code null} + */ @Override public List findByAlbum(Album album) { if (album == null) { @@ -92,26 +145,4 @@ public List findByAlbum(Album album) { .setParameter("album", album) .getResultList()); } - - @Override - public List findByGenre(String genre) { - if (genre == null || genre.isBlank()) { - logger.debug("findByGenre: genre is null or blank"); - return new ArrayList<>(); - } - - return emf.callInTransaction(em -> - em.createQuery( - """ - select s - from Song s - join fetch s.album a - join fetch a.artist art - where lower(a.genre) = lower(:genre) - """, - Song.class - ) - .setParameter("genre", genre) - .getResultList()); - } } diff --git a/src/test/java/org/example/AlbumRepoTest.java b/src/test/java/org/example/AlbumRepoTest.java index 94729a1f..b03f1a66 100644 --- a/src/test/java/org/example/AlbumRepoTest.java +++ b/src/test/java/org/example/AlbumRepoTest.java @@ -2,6 +2,7 @@ import org.example.entity.Album; import org.example.entity.Artist; +import org.example.repo.AlbumRepositoryImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +10,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Integration tests for {@link AlbumRepositoryImpl}. + */ @DisplayName("Album Repository Tests") public class AlbumRepoTest extends RepoTest { diff --git a/src/test/java/org/example/AppIT.java b/src/test/java/org/example/AppIT.java deleted file mode 100644 index 9d1ca031..00000000 --- a/src/test/java/org/example/AppIT.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.example; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class AppIT { - @Test - void itTest() { - assertThat(false).isFalse(); - } -} diff --git a/src/test/java/org/example/ArtistRepoTest.java b/src/test/java/org/example/ArtistRepoTest.java index 2e73365d..272b112c 100644 --- a/src/test/java/org/example/ArtistRepoTest.java +++ b/src/test/java/org/example/ArtistRepoTest.java @@ -1,6 +1,7 @@ package org.example; import org.example.entity.Artist; +import org.example.repo.ArtistRepositoryImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -8,6 +9,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Integration tests for {@link ArtistRepositoryImpl}. + */ @DisplayName("Artist Repository Tests") public class ArtistRepoTest extends RepoTest { diff --git a/src/test/java/org/example/PlaylistRepoTest.java b/src/test/java/org/example/PlaylistRepoTest.java index 359f3ca1..2dfaf9a8 100644 --- a/src/test/java/org/example/PlaylistRepoTest.java +++ b/src/test/java/org/example/PlaylistRepoTest.java @@ -2,6 +2,8 @@ import org.example.entity.Playlist; import org.example.entity.Song; +import org.example.repo.AlbumRepositoryImpl; +import org.example.repo.PlaylistRepositoryImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +11,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Integration tests for {@link PlaylistRepositoryImpl}. + */ @DisplayName("Playlist Repository Tests") public class PlaylistRepoTest extends RepoTest { diff --git a/src/test/java/org/example/RepoTest.java b/src/test/java/org/example/RepoTest.java index 3a5fd7fb..116155dc 100644 --- a/src/test/java/org/example/RepoTest.java +++ b/src/test/java/org/example/RepoTest.java @@ -14,8 +14,17 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Base test class for repository integration tests. + * + *

This class provides a shared test setup for all repository test cases. + * It initializes an in-memory (or test-specific) persistence context and + * populates it with a consistent set of test entities.

+ * + *

Concrete repository test classes should extend this class in order + * to reuse the same test data and repository instances.

+ */ public class RepoTest { - protected AlbumRepositoryImpl albumRepo; protected ArtistRepositoryImpl artistRepo; protected PlaylistRepositoryImpl playlistRepo; @@ -33,16 +42,31 @@ public class RepoTest { protected Song testSong4; protected Song testSong5; + /** + * Initializes repositories and persists test entities before each test. + * + *

This ensures that every test is executed against a fresh and + * predictable database state.

+ */ @BeforeEach void setup() { initTestObjects(); } + /** + * Cleans up persistence resources after each test execution. + */ @AfterEach void tearDown() { TestPersistenceManager.close(); } + /** + * Creates and persists a fixed set of test entities. + * + *

The dataset includes multiple artists, albums, and songs + * to support repository queries, filtering, and relationship testing.

+ */ void initTestObjects() { artistRepo = new ArtistRepositoryImpl(TestPersistenceManager.get()); albumRepo = new AlbumRepositoryImpl(TestPersistenceManager.get()); diff --git a/src/test/java/org/example/SongRepoTest.java b/src/test/java/org/example/SongRepoTest.java index b3c45e3a..79002b2e 100644 --- a/src/test/java/org/example/SongRepoTest.java +++ b/src/test/java/org/example/SongRepoTest.java @@ -2,6 +2,7 @@ import org.example.entity.Artist; import org.example.entity.Song; +import org.example.repo.SongRepositoryImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +10,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Integration tests for {@link SongRepositoryImpl}. + */ @DisplayName("Song Repository Tests") public class SongRepoTest extends RepoTest { diff --git a/src/test/java/org/example/TestPersistenceManager.java b/src/test/java/org/example/TestPersistenceManager.java index 1d90cc23..a11da827 100644 --- a/src/test/java/org/example/TestPersistenceManager.java +++ b/src/test/java/org/example/TestPersistenceManager.java @@ -4,13 +4,39 @@ import java.util.Map; +/** + * Provides a lazily initialized {@link EntityManagerFactory} for test execution. + * + *

This class is intended exclusively for use in automated tests. It creates + * an in-memory H2 database with a schema lifecycle tailored for testing + * (create-drop), ensuring full isolation between test runs.

+ * + *

The {@link EntityManagerFactory} is initialized on first access and + * reused for the duration of a test suite. It should be explicitly closed + * after each test (or test class) to guarantee a clean persistence state.

+ */ public final class TestPersistenceManager { + /** Singleton {@link EntityManagerFactory} instance for tests. */ private static EntityManagerFactory emf; + /** + * Private constructor to prevent instantiation. + * + *

This class is a static utility and should never be instantiated.

+ */ private TestPersistenceManager() { } + /** + * Returns the test {@link EntityManagerFactory}, creating it if necessary. + * + *

The factory is configured to use an in-memory H2 database with + * automatic schema creation and teardown. The database remains alive + * for the duration of the JVM to support multiple transactions per test.

+ * + * @return a configured {@link EntityManagerFactory} for testing purposes + */ public static EntityManagerFactory get() { if (emf == null) { emf = EntityManagerFactoryProvider.create( @@ -27,6 +53,13 @@ public static EntityManagerFactory get() { return emf; } + /** + * Closes the test {@link EntityManagerFactory} and releases all resources. + * + *

This method should be called after each test or test suite to ensure + * a clean shutdown of the persistence context and prevent state leakage + * between tests.

+ */ public static void close() { if (emf != null) { emf.close();