diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..604efe2 --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,181 @@ +/* +Copyright © 2025 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Implements generic access to files inside an archive. +// +// An archive in this context can be any container that holds files. +// In this implementation the archive can be a ZIP file or a directory. +package archive + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" +) + +// Generic archive interface. +type Archive interface { + Close() error + OpenFile(path string) (io.ReadCloser, error) + Glob(pattern string) ([]FileInfo, error) +} + +// Open an archive in path. +// The archive can be a ZIP file or a directory. The implementation +// will detect the type of archive and return the appropriate +// implementation of the Archive interface. +func OpenArchive(path string) (Archive, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + switch mode := fi.Mode(); { + case mode.IsRegular(): + archive := &zipArchive{} + err := archive.Open(path) + if err != nil { + return nil, err + } + return archive, nil + case mode.IsDir(): + archive := &dirArchive{} + err := archive.Open(path) + if err != nil { + return nil, err + } + return archive, nil + default: + return nil, fmt.Errorf("unsupported file mode: %s", mode) + } +} + +// Interface for a file that can be opened when needed. +type OpenableFile interface { + Open() (io.ReadCloser, error) +} + +// Generic information about a file inside an archive. +type FileInfo struct { + Name string + File OpenableFile +} + +// A openable file in the filesystem. +type filesystemFile struct { + path string +} + +func (f *filesystemFile) Open() (io.ReadCloser, error) { + return os.Open(f.path) +} + +// An implementation of the archiveBackend interface for zip files. +type zipArchive struct { + zip *zip.ReadCloser +} + +func (a *zipArchive) Open(path string) error { + zip, err := zip.OpenReader(path) + if err != nil { + return err + } + a.zip = zip + return nil +} + +func (a *zipArchive) Close() error { + if a.zip == nil { + return nil + } + return a.zip.Close() +} + +func (a *zipArchive) Glob(pattern string) ([]FileInfo, error) { + result := make([]FileInfo, 0) + for _, file := range a.zip.File { + if matched, err := filepath.Match(pattern, file.Name); matched { + if err != nil { + return nil, err + } + info := FileInfo{ + Name: file.Name, + File: file, + } + result = append(result, info) + } + } + + return result, nil +} + +func (a *zipArchive) OpenFile(path string) (io.ReadCloser, error) { + file, err := a.zip.Open(path) + if err != nil { + return nil, err + } + return file, nil +} + +// An implementation of the archiveBackend interface for directories. +type dirArchive struct { + dir string +} + +func (a *dirArchive) Open(path string) error { + a.dir = filepath.Clean(path) + return nil +} + +func (a *dirArchive) Close() error { + return nil +} + +func (a *dirArchive) OpenFile(path string) (io.ReadCloser, error) { + file, err := os.Open(filepath.Join(a.dir, path)) + if err != nil { + return nil, err + } + return file, nil +} + +func (a *dirArchive) Glob(pattern string) ([]FileInfo, error) { + files, err := filepath.Glob(filepath.Join(a.dir, pattern)) + if err != nil { + return nil, err + } + result := make([]FileInfo, 0) + for _, filename := range files { + name, err := filepath.Rel(a.dir, filename) + if err != nil { + return nil, err + } + info := FileInfo{ + Name: name, + File: &filesystemFile{path: filename}, + } + result = append(result, info) + } + + return result, nil +} diff --git a/pkg/listenbrainz/archive.go b/pkg/listenbrainz/archive.go index de34ba8..a455d03 100644 --- a/pkg/listenbrainz/archive.go +++ b/pkg/listenbrainz/archive.go @@ -22,19 +22,16 @@ THE SOFTWARE. package listenbrainz import ( - "archive/zip" "encoding/json" - "fmt" "io" "iter" - "os" - "path/filepath" "regexp" "sort" "strconv" "time" "github.com/simonfrey/jsonl" + "go.uploadedlobster.com/scotty/internal/archive" ) // Represents a ListenBrainz export archive. @@ -42,7 +39,17 @@ import ( // The export contains the user's listen history, favorite tracks and // user information. type Archive struct { - backend archiveBackend + backend archive.Archive +} + +// Open a ListenBrainz archive from file path. +func OpenArchive(path string) (*Archive, error) { + backend, err := archive.OpenArchive(path) + if err != nil { + return nil, err + } + + return &Archive{backend: backend}, nil } // Close the archive and release any resources. @@ -137,166 +144,27 @@ func (a *Archive) IterListens(minTimestamp time.Time) iter.Seq2[Listen, error] { } } -// Open a ListenBrainz archive from file path. -func OpenArchive(path string) (*Archive, error) { - fi, err := os.Stat(path) - if err != nil { - return nil, err - } - switch mode := fi.Mode(); { - case mode.IsRegular(): - backend := &zipArchive{} - err := backend.Open(path) - if err != nil { - return nil, err - } - return &Archive{backend: backend}, nil - case mode.IsDir(): - backend := &dirArchive{} - err := backend.Open(path) - if err != nil { - return nil, err - } - return &Archive{backend: backend}, nil - default: - return nil, fmt.Errorf("unsupported file mode: %s", mode) - } -} - type UserInfo struct { ID string `json:"user_id"` Name string `json:"username"` } -type archiveBackend interface { - Close() error - OpenFile(path string) (io.ReadCloser, error) - Glob(pattern string) ([]FileInfo, error) -} - type timeRange struct { Start time.Time End time.Time } -type OpenableFile interface { - Open() (io.ReadCloser, error) -} - -type FileInfo struct { - Name string - File OpenableFile -} - -type FilesystemFile struct { - path string -} - -func (f *FilesystemFile) Open() (io.ReadCloser, error) { - return os.Open(f.path) -} - type ListenExportFileInfo struct { Name string TimeRange timeRange - f OpenableFile -} - -// An implementation of the archiveBackend interface for zip files. -type zipArchive struct { - zip *zip.ReadCloser -} - -func (a *zipArchive) Open(path string) error { - zip, err := zip.OpenReader(path) - if err != nil { - return err - } - a.zip = zip - return nil -} - -func (a *zipArchive) Close() error { - if a.zip == nil { - return nil - } - return a.zip.Close() -} - -func (a *zipArchive) Glob(pattern string) ([]FileInfo, error) { - result := make([]FileInfo, 0) - for _, file := range a.zip.File { - if matched, err := filepath.Match(pattern, file.Name); matched { - if err != nil { - return nil, err - } - info := FileInfo{ - Name: file.Name, - File: file, - } - result = append(result, info) - } - } - - return result, nil -} - -func (a *zipArchive) OpenFile(path string) (io.ReadCloser, error) { - file, err := a.zip.Open(path) - if err != nil { - return nil, err - } - return file, nil -} - -// An implementation of the archiveBackend interface for directories. -type dirArchive struct { - dir string -} - -func (a *dirArchive) Open(path string) error { - a.dir = filepath.Clean(path) - return nil -} - -func (a *dirArchive) Close() error { - return nil -} - -func (a *dirArchive) OpenFile(path string) (io.ReadCloser, error) { - file, err := os.Open(filepath.Join(a.dir, path)) - if err != nil { - return nil, err - } - return file, nil -} - -func (a *dirArchive) Glob(pattern string) ([]FileInfo, error) { - files, err := filepath.Glob(filepath.Join(a.dir, pattern)) - if err != nil { - return nil, err - } - result := make([]FileInfo, 0) - for _, filename := range files { - name, err := filepath.Rel(a.dir, filename) - if err != nil { - return nil, err - } - info := FileInfo{ - Name: name, - File: &FilesystemFile{path: filename}, - } - result = append(result, info) - } - - return result, nil + f archive.OpenableFile } type ListenExportFile struct { - file OpenableFile + file archive.OpenableFile } -func NewExportFile(f OpenableFile) ListenExportFile { +func NewExportFile(f archive.OpenableFile) ListenExportFile { return ListenExportFile{file: f} }