···9type Model interface {
10 // GetID returns the primary key identifier
11 GetID() int64
12-13 // SetID sets the primary key identifier
14 SetID(id int64)
15-16 // GetTableName returns the database table name for this model
17 GetTableName() string
18-19 // GetCreatedAt returns when the model was created
20 GetCreatedAt() time.Time
21-22 // SetCreatedAt sets when the model was created
23 SetCreatedAt(t time.Time)
24-25 // GetUpdatedAt returns when the model was last updated
26 GetUpdatedAt() time.Time
27-28 // SetUpdatedAt sets when the model was last updated
29 SetUpdatedAt(t time.Time)
30}
···104 Created time.Time `json:"created"`
105 Modified time.Time `json:"modified"`
106 FilePath string `json:"file_path,omitempty"`
000000000000000107}
108109// MarshalTags converts tags slice to JSON string for database storage
···269func (n *Note) SetCreatedAt(time time.Time) { n.Created = time }
270func (n *Note) GetUpdatedAt() time.Time { return n.Modified }
271func (n *Note) SetUpdatedAt(time time.Time) { n.Modified = time }
000000000000000000000000000000000000
···9type Model interface {
10 // GetID returns the primary key identifier
11 GetID() int64
012 // SetID sets the primary key identifier
13 SetID(id int64)
014 // GetTableName returns the database table name for this model
15 GetTableName() string
016 // GetCreatedAt returns when the model was created
17 GetCreatedAt() time.Time
018 // SetCreatedAt sets when the model was created
19 SetCreatedAt(t time.Time)
020 // GetUpdatedAt returns when the model was last updated
21 GetUpdatedAt() time.Time
022 // SetUpdatedAt sets when the model was last updated
23 SetUpdatedAt(t time.Time)
24}
···98 Created time.Time `json:"created"`
99 Modified time.Time `json:"modified"`
100 FilePath string `json:"file_path,omitempty"`
101+}
102+103+// Album represents a music album
104+type Album struct {
105+ ID int64 `json:"id"`
106+ Title string `json:"title"`
107+ Artist string `json:"artist"`
108+ Genre string `json:"genre,omitempty"`
109+ ReleaseYear int `json:"release_year,omitempty"`
110+ Tracks []string `json:"tracks,omitempty"`
111+ DurationSeconds int `json:"duration_seconds,omitempty"`
112+ AlbumArtPath string `json:"album_art_path,omitempty"`
113+ Rating int `json:"rating,omitempty"`
114+ Created time.Time `json:"created"`
115+ Modified time.Time `json:"modified"`
116}
117118// MarshalTags converts tags slice to JSON string for database storage
···278func (n *Note) SetCreatedAt(time time.Time) { n.Created = time }
279func (n *Note) GetUpdatedAt() time.Time { return n.Modified }
280func (n *Note) SetUpdatedAt(time time.Time) { n.Modified = time }
281+282+// MarshalTracks converts tracks slice to JSON string for database storage
283+func (a *Album) MarshalTracks() (string, error) {
284+ if len(a.Tracks) == 0 {
285+ return "", nil
286+ }
287+ data, err := json.Marshal(a.Tracks)
288+ return string(data), err
289+}
290+291+// UnmarshalTracks converts JSON string from database to tracks slice
292+func (a *Album) UnmarshalTracks(data string) error {
293+ if data == "" {
294+ a.Tracks = nil
295+ return nil
296+ }
297+ return json.Unmarshal([]byte(data), &a.Tracks)
298+}
299+300+// HasRating returns true if the album has a rating set
301+func (a *Album) HasRating() bool {
302+ return a.Rating > 0
303+}
304+305+// IsValidRating returns true if the rating is between 1 and 5
306+func (a *Album) IsValidRating() bool {
307+ return a.Rating >= 1 && a.Rating <= 5
308+}
309+310+func (a *Album) GetID() int64 { return a.ID }
311+func (a *Album) SetID(id int64) { a.ID = id }
312+func (a *Album) GetTableName() string { return "albums" }
313+func (a *Album) GetCreatedAt() time.Time { return a.Created }
314+func (a *Album) SetCreatedAt(time time.Time) { a.Created = time }
315+func (a *Album) GetUpdatedAt() time.Time { return a.Modified }
316+func (a *Album) SetUpdatedAt(time time.Time) { a.Modified = time }
+176-3
internal/models/models_test.go
···669 })
670 })
6710000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000672 t.Run("Interface Implementations", func(t *testing.T) {
673 t.Run("All models implement Model interface", func(t *testing.T) {
674 var models []Model
···678 tvShow := &TVShow{}
679 book := &Book{}
680 note := &Note{}
0681682- models = append(models, task, movie, tvShow, book, note)
683684- if len(models) != 5 {
685- t.Errorf("Expected 5 models, got %d", len(models))
686 }
687688 // Test that all models have the required methods
···669 })
670 })
671672+ t.Run("Album Model", func(t *testing.T) {
673+ t.Run("Model Interface Implementation", func(t *testing.T) {
674+ album := &Album{
675+ ID: 1,
676+ Title: "Test Album",
677+ Artist: "Test Artist",
678+ Created: time.Now(),
679+ }
680+681+ if album.GetID() != 1 {
682+ t.Errorf("Expected ID 1, got %d", album.GetID())
683+ }
684+685+ album.SetID(2)
686+ if album.GetID() != 2 {
687+ t.Errorf("Expected ID 2 after SetID, got %d", album.GetID())
688+ }
689+690+ if album.GetTableName() != "albums" {
691+ t.Errorf("Expected table name 'albums', got '%s'", album.GetTableName())
692+ }
693+694+ createdAt := time.Now()
695+ album.SetCreatedAt(createdAt)
696+ if !album.GetCreatedAt().Equal(createdAt) {
697+ t.Errorf("Expected created at %v, got %v", createdAt, album.GetCreatedAt())
698+ }
699+700+ updatedAt := time.Now().Add(time.Hour)
701+ album.SetUpdatedAt(updatedAt)
702+ if !album.GetUpdatedAt().Equal(updatedAt) {
703+ t.Errorf("Expected updated at %v, got %v", updatedAt, album.GetUpdatedAt())
704+ }
705+ })
706+707+ t.Run("Rating Methods", func(t *testing.T) {
708+ album := &Album{}
709+710+ if album.HasRating() {
711+ t.Error("Album with zero rating should return false for HasRating")
712+ }
713+714+ if album.IsValidRating() {
715+ t.Error("Album with zero rating should return false for IsValidRating")
716+ }
717+718+ album.Rating = 3
719+ if !album.HasRating() {
720+ t.Error("Album with rating should return true for HasRating")
721+ }
722+723+ if !album.IsValidRating() {
724+ t.Error("Album with valid rating should return true for IsValidRating")
725+ }
726+727+ testCases := []struct {
728+ rating int
729+ isValid bool
730+ }{
731+ {0, false},
732+ {1, true},
733+ {3, true},
734+ {5, true},
735+ {6, false},
736+ {-1, false},
737+ }
738+739+ for _, tc := range testCases {
740+ album.Rating = tc.rating
741+ if album.IsValidRating() != tc.isValid {
742+ t.Errorf("Rating %d: expected IsValidRating %v, got %v", tc.rating, tc.isValid, album.IsValidRating())
743+ }
744+ }
745+ })
746+747+ t.Run("Tracks Marshaling", func(t *testing.T) {
748+ album := &Album{}
749+750+ result, err := album.MarshalTracks()
751+ if err != nil {
752+ t.Fatalf("MarshalTracks failed: %v", err)
753+ }
754+ if result != "" {
755+ t.Errorf("Expected empty string for empty tracks, got '%s'", result)
756+ }
757+758+ album.Tracks = []string{"Track 1", "Track 2", "Interlude"}
759+ result, err = album.MarshalTracks()
760+ if err != nil {
761+ t.Fatalf("MarshalTracks failed: %v", err)
762+ }
763+764+ expected := `["Track 1","Track 2","Interlude"]`
765+ if result != expected {
766+ t.Errorf("Expected %s, got %s", expected, result)
767+ }
768+769+ newAlbum := &Album{}
770+ err = newAlbum.UnmarshalTracks(result)
771+ if err != nil {
772+ t.Fatalf("UnmarshalTracks failed: %v", err)
773+ }
774+775+ if len(newAlbum.Tracks) != 3 {
776+ t.Errorf("Expected 3 tracks, got %d", len(newAlbum.Tracks))
777+ }
778+ if newAlbum.Tracks[0] != "Track 1" || newAlbum.Tracks[1] != "Track 2" || newAlbum.Tracks[2] != "Interlude" {
779+ t.Errorf("Tracks not unmarshaled correctly: %v", newAlbum.Tracks)
780+ }
781+782+ emptyAlbum := &Album{}
783+ err = emptyAlbum.UnmarshalTracks("")
784+ if err != nil {
785+ t.Fatalf("UnmarshalTracks with empty string failed: %v", err)
786+ }
787+ if emptyAlbum.Tracks != nil {
788+ t.Error("Expected nil tracks for empty string")
789+ }
790+ })
791+792+ t.Run("JSON Marshaling", func(t *testing.T) {
793+ now := time.Now()
794+ modified := now.Add(time.Hour)
795+ album := &Album{
796+ ID: 1,
797+ Title: "Test Album",
798+ Artist: "Test Artist",
799+ Genre: "Rock",
800+ ReleaseYear: 2023,
801+ Tracks: []string{"Track 1", "Track 2"},
802+ DurationSeconds: 3600,
803+ AlbumArtPath: "/path/to/art.jpg",
804+ Rating: 4,
805+ Created: now,
806+ Modified: modified,
807+ }
808+809+ data, err := json.Marshal(album)
810+ if err != nil {
811+ t.Fatalf("JSON marshal failed: %v", err)
812+ }
813+814+ var unmarshaled Album
815+ err = json.Unmarshal(data, &unmarshaled)
816+ if err != nil {
817+ t.Fatalf("JSON unmarshal failed: %v", err)
818+ }
819+820+ if unmarshaled.ID != album.ID {
821+ t.Errorf("Expected ID %d, got %d", album.ID, unmarshaled.ID)
822+ }
823+ if unmarshaled.Title != album.Title {
824+ t.Errorf("Expected title %s, got %s", album.Title, unmarshaled.Title)
825+ }
826+ if unmarshaled.Artist != album.Artist {
827+ t.Errorf("Expected artist %s, got %s", album.Artist, unmarshaled.Artist)
828+ }
829+ if unmarshaled.Genre != album.Genre {
830+ t.Errorf("Expected genre %s, got %s", album.Genre, unmarshaled.Genre)
831+ }
832+ if unmarshaled.ReleaseYear != album.ReleaseYear {
833+ t.Errorf("Expected release year %d, got %d", album.ReleaseYear, unmarshaled.ReleaseYear)
834+ }
835+ if unmarshaled.DurationSeconds != album.DurationSeconds {
836+ t.Errorf("Expected duration %d, got %d", album.DurationSeconds, unmarshaled.DurationSeconds)
837+ }
838+ if unmarshaled.Rating != album.Rating {
839+ t.Errorf("Expected rating %d, got %d", album.Rating, unmarshaled.Rating)
840+ }
841+ })
842+ })
843+844 t.Run("Interface Implementations", func(t *testing.T) {
845 t.Run("All models implement Model interface", func(t *testing.T) {
846 var models []Model
···850 tvShow := &TVShow{}
851 book := &Book{}
852 note := &Note{}
853+ album := &Album{}
854855+ models = append(models, task, movie, tvShow, book, note, album)
856857+ if len(models) != 6 {
858+ t.Errorf("Expected 6 models, got %d", len(models))
859 }
860861 // Test that all models have the required methods
+20
internal/services/services.go
···00000000000000000000
···1+// Movies & TV: Rotten Tomatoes with colly
2+//
3+// Music: Album of the Year with chromedp
4+//
5+// Books: OpenLibrary API
6+package services
7+8+import (
9+ "context"
10+11+ "github.com/stormlightlabs/noteleaf/internal/models"
12+)
13+14+// APIService defines the contract for API interactions
15+type APIService interface {
16+ Get(ctx context.Context, id string) (*models.Model, error)
17+ Search(ctx context.Context, page, limit int) ([]*models.Model, error)
18+ Check(ctx context.Context) error
19+ Close() error
20+}
···1+CREATE TABLE IF NOT EXISTS albums (
2+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3+ title TEXT NOT NULL,
4+ artist TEXT NOT NULL,
5+ genre TEXT,
6+ release_year INTEGER,
7+ tracks TEXT, -- JSON array of track names
8+ duration_seconds INTEGER,
9+ album_art_path TEXT,
10+ rating INTEGER CHECK (rating >= 1 AND rating <= 5),
11+ created DATETIME DEFAULT CURRENT_TIMESTAMP,
12+ modified DATETIME DEFAULT CURRENT_TIMESTAMP
13+);
14+15+CREATE TRIGGER update_albums_modified
16+ AFTER UPDATE ON albums
17+ FOR EACH ROW
18+ BEGIN
19+ UPDATE albums SET modified = CURRENT_TIMESTAMP WHERE id = NEW.id;
20+ END;