diff --git a/.bob/notes/pending-notes.txt b/.bob/notes/pending-notes.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/jbang/jdkdb/DownloadCommand.java b/src/main/java/dev/jbang/jdkdb/DownloadCommand.java index 159609e..e04cac6 100644 --- a/src/main/java/dev/jbang/jdkdb/DownloadCommand.java +++ b/src/main/java/dev/jbang/jdkdb/DownloadCommand.java @@ -18,12 +18,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; -import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; import java.util.stream.Collectors; @@ -112,7 +110,8 @@ public Integer call() throws Exception { GitHubUtils.setupGitHubToken(); // Process file type filter - Set fileTypeFilter = processFileTypeFilter(includeFileTypes, excludeFileTypes); + Set fileTypeFilter = + JdkMetadata.processFileTypeFilter(includeFileTypes, excludeFileTypes); logger.info("Java Metadata Scraper - Download"); logger.info("================================="); @@ -295,37 +294,6 @@ static List prioritizeMetadata(List metadataList, bool return ordered; } - /** - * Process the include and exclude file type options to create a filter set. - * - * @param includeFileTypes List of file types to include (null or empty means include all) - * @param excludeFileTypes List of file types to exclude (null or empty means exclude none) - * @return A set of file types to accept, or null if no filtering should be applied - */ - private Set processFileTypeFilter( - List includeFileTypes, List excludeFileTypes) { - if ((includeFileTypes == null || includeFileTypes.isEmpty()) - && (excludeFileTypes == null || excludeFileTypes.isEmpty())) { - return null; // No filtering - } - - Set result; - if (includeFileTypes != null && !includeFileTypes.isEmpty()) { - // Start with only the included types - result = EnumSet.copyOf(includeFileTypes); - } else { - // Start with all types - result = EnumSet.allOf(JdkMetadata.FileType.class); - } - - // Remove excluded types - if (excludeFileTypes != null && !excludeFileTypes.isEmpty()) { - result.removeAll(excludeFileTypes); - } - - return result.isEmpty() ? null : result; - } - public static class DownloadProcessor implements DefaultDownloadManager.DownloadProcessor { private final HttpUtils httpUtils; private final Path metadataDir; @@ -344,7 +312,7 @@ public void processDownload(DownloadTask task) throws IOException, InterruptedEx JdkMetadata metadata = task.metadata(); String filename = metadata.getFilename(); String url = metadata.getUrl(); - Optional unlistedSince = findUnlistedSince(metadata); + String today = java.time.LocalDate.now().toString(); if (filename == null || url == null) { return; @@ -358,28 +326,27 @@ public void processDownload(DownloadTask task) throws IOException, InterruptedEx Path tempFile = Files.createTempFile("jdk-metadata-", "-" + filename); try { - if (unlistedSince.isPresent()) { + if (metadata.getUnlistedSince() != null) { task.downloadLogger() .info( "Metadata item {} has unlisted_since={} - attempting download", filename, - unlistedSince.get()); + metadata.getUnlistedSince()); } task.downloadLogger().info("Downloading " + filename); try { httpUtils.downloadFile(url, tempFile); } catch (IOException e) { if (isHttpStatus(e, 403, 404)) { - if (unlistedSince.isPresent()) { + if (metadata.getUnlistedSince() != null) { task.downloadLogger() .warn( "Download returned 40X for unlisted package {} (unlisted_since={}). " + "This package is most likely not available anymore and is a candidate for pruning.", filename, - unlistedSince.get()); + metadata.getUnlistedSince()); } if (markMissing && metadata.getMissingSince() == null) { - String today = java.time.LocalDate.now().toString(); metadata.setMissingSince(today); task.downloadLogger().warn("Marking {} as missing_since={}", filename, today); saveMetadata(task, metadataDir, metadata); @@ -412,6 +379,7 @@ public void processDownload(DownloadTask task) throws IOException, InterruptedEx task.downloadLogger().info("Clearing missing_since for {}", filename); metadata.setMissingSince(null); } + metadata.setLastVerified(today); // Extract and parse release info from archive try { @@ -464,14 +432,6 @@ private static void saveChecksumFile(Path checksumDir, String filename, String a Files.writeString(checksumFile, checksum + " " + filename + "\n"); } - private static Optional findUnlistedSince(JdkMetadata metadata) { - String value = metadata.getUnlistedSince(); - if (value == null || value.isBlank()) { - return Optional.empty(); - } - return Optional.of(value); - } - private static boolean isHttpStatus(IOException e, int... statusCodes) { if (!(e instanceof HttpStatusException hse)) { return false; diff --git a/src/main/java/dev/jbang/jdkdb/MainCommand.java b/src/main/java/dev/jbang/jdkdb/MainCommand.java index 0dc5cbb..94e4879 100644 --- a/src/main/java/dev/jbang/jdkdb/MainCommand.java +++ b/src/main/java/dev/jbang/jdkdb/MainCommand.java @@ -16,6 +16,7 @@ UpdateCommand.class, IndexCommand.class, DownloadCommand.class, + VerifyCommand.class, CleanCommand.class, ConvertCommand.class }) diff --git a/src/main/java/dev/jbang/jdkdb/VerifyCommand.java b/src/main/java/dev/jbang/jdkdb/VerifyCommand.java new file mode 100644 index 0000000..23fa3b5 --- /dev/null +++ b/src/main/java/dev/jbang/jdkdb/VerifyCommand.java @@ -0,0 +1,368 @@ +package dev.jbang.jdkdb; + +import dev.jbang.jdkdb.model.JdkMetadata; +import dev.jbang.jdkdb.scraper.DefaultDownloadManager; +import dev.jbang.jdkdb.scraper.DefaultDownloadManager.DownloadTask; +import dev.jbang.jdkdb.scraper.DownloadManager; +import dev.jbang.jdkdb.scraper.InterruptedProgressException; +import dev.jbang.jdkdb.scraper.NoOpDownloadManager; +import dev.jbang.jdkdb.util.GitHubUtils; +import dev.jbang.jdkdb.util.HttpUtils; +import dev.jbang.jdkdb.util.MetadataUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** Verify command to check that metadata download URLs are still valid */ +@Command( + name = "verify", + description = "Verifies that metadata download URLs are still valid", + mixinStandardHelpOptions = true) +public class VerifyCommand implements Callable { + private static final Logger logger = LoggerFactory.getLogger("command"); + + @Option( + names = {"-m", "--metadata-dir"}, + description = "Directory containing metadata files (default: db/metadata)", + defaultValue = "db/metadata") + private Path metadataDir; + + @Option( + names = {"-c", "--checksum-dir"}, + description = "Directory to store checksum files (default: db/checksums)", + defaultValue = "db/checksums") + private Path checksumDir; + + @Option( + names = {"-v", "--distros"}, + description = + "Comma-separated list of distro names to process (if not specified, all distros are processed)", + split = ",") + private List distroNames; + + @Option( + names = {"-t", "--threads"}, + description = "Maximum number of parallel download threads (default: number of processors)", + defaultValue = "-1") + private int maxThreads; + + @Option( + names = {"--limit-progress"}, + description = + "Maximum number of metadata items to process per scraper before aborting (default: unlimited)", + defaultValue = "-1") + private int limitProgress; + + @Option( + names = {"--limit-total"}, + description = "Maximum total number of downloads to accept before stopping (default: unlimited)", + defaultValue = "-1") + private int limitTotal; + + @Option( + names = {"--stats-only"}, + description = "Skip downloading files and only show statistics (for testing/dry-run)") + private boolean statsOnly; + + @Option( + names = {"--include"}, + description = + "Include only these file types (e.g., tar_gz,zip). If specified, only these types will be downloaded.", + split = ",") + private List includeFileTypes; + + @Option( + names = {"--exclude"}, + description = "Exclude these file types (e.g., msi,exe). These types will not be downloaded.", + split = ",") + private List excludeFileTypes; + + @Option( + names = {"--randomize"}, + description = "Randomize the order of downloads instead of processing files in order") + private boolean randomize; + + @Option( + names = {"--mark-missing"}, + description = "Mark files as missing_since when they return 403/404 during download") + private boolean markMissing; + + @Override + public Integer call() throws Exception { + GitHubUtils.setupGitHubToken(); + + // Process file type filter + Set fileTypeFilter = + JdkMetadata.processFileTypeFilter(includeFileTypes, excludeFileTypes); + + logger.info("Java Metadata Scraper - Verify"); + logger.info("================================="); + logger.info("Metadata directory: {}", metadataDir.toAbsolutePath()); + logger.info("Checksum directory: {}", checksumDir.toAbsolutePath()); + logger.info(""); + + Path distroDir = metadataDir; + if (!Files.exists(distroDir) || !Files.isDirectory(distroDir)) { + logger.error("Error: Distro directory not found: {}", distroDir.toAbsolutePath()); + return 1; + } + + // Determine which distros to process + List distrosToProcess; + if (distroNames == null || distroNames.isEmpty()) { + // Process all distros + try (Stream paths = Files.list(distroDir)) { + distrosToProcess = paths.filter(Files::isDirectory) + .map(Path::getFileName) + .map(Path::toString) + .sorted() + .toList(); + } + logger.info("Processing all distros..."); + } else { + distrosToProcess = distroNames; + logger.info("Processing specified distros: {}", String.join(", ", distroNames)); + } + logger.info(""); + + // Create download manager + var threadCount = maxThreads > 0 ? maxThreads : Runtime.getRuntime().availableProcessors(); + VerifyProcessor verifyProcessor = new VerifyProcessor(new HttpUtils(), metadataDir, markMissing); + DownloadManager downloadManager = statsOnly + ? new NoOpDownloadManager(fileTypeFilter) + : new DefaultDownloadManager(threadCount, 3, limitTotal, fileTypeFilter, verifyProcessor); + downloadManager.start(); + if (fileTypeFilter != null) { + logger.info("File type filter enabled: {}", fileTypeFilter); + } + + List metadataList = MetadataUtils.collectAllMetadata(distroDir, 2, true, false).stream() + .filter(md -> md.getMissingSince() == null) // Don't process items marked as missing + .filter(md -> md.getLastVerified() + == null) // Don't process items that have been verified (TODO introduce flag to configure this) + .collect(Collectors.toCollection(ArrayList::new)); + + metadataList = prioritizeUnlistedEa(metadataList, randomize); + if (randomize) { + logger.info("Randomized download order (preserving unlisted-first priority)"); + } + + Map distroMissingCounts = new HashMap<>(); + Set distrosAtProgressLimit = new HashSet<>(); + for (JdkMetadata metadata : metadataList) { + try { + String distroName = metadata.getDistro(); + if (!distrosToProcess.contains(distroName)) { + continue; // Skip distros not in the specified list + } + if (limitProgress > 0 && distrosAtProgressLimit.contains(distroName)) { + continue; + } + Logger dl = LoggerFactory.getLogger("distros." + distroName); + downloadManager.submit(metadata, distroName, dl); + distroMissingCounts.put(distroName, distroMissingCounts.getOrDefault(distroName, 0) + 1); + int distroMissing = distroMissingCounts.get(distroName); + if (limitProgress > 0 && distroMissing >= limitProgress) { + distrosAtProgressLimit.add(distroName); + dl.info( + "Reached progress limit of {} items for distro {}, skipping remaining files for this distro", + limitProgress, + distroName); + logger.info( + "Reached progress limit of {} items for distro {}, skipping remaining files for this distro", + limitProgress, + distroName); + continue; + } + } catch (InterruptedProgressException e) { + logger.info("Progress limit reached, stopping submission of new downloads"); + break; + } catch (Exception e) { + logger.error( + "Failed to read metadata file: {} - {}", + Path.of(metadata.getFilename()).getFileName(), + e.getMessage()); + } + } + + downloadManager.shutdown(); + + int filesWithMissingData = metadataList.size(); + int totalCompleted = 0; + int totalFailed = 0; + + logger.info("Waiting for downloads to complete..."); + try { + downloadManager.awaitCompletion(); + totalCompleted = downloadManager.getCompletedCount(); + totalFailed = downloadManager.getFailedCount(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Download manager interrupted while waiting for completion"); + return 1; + } + + logger.info("Summary"); + logger.info("======="); + logger.info("Files with missing data: {}", filesWithMissingData); + if (filesWithMissingData > 0) { + // Per-distro breakdown + Map distroStats = downloadManager.getDistroStats(); + if (!distroStats.isEmpty()) { + logger.info("Per-Distro Breakdown"); + logger.info("===================="); + distroStats.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + DownloadManager.DistroStats stats = entry.getValue(); + logger.info(" {}:", stats.distro()); + logger.info(" Submitted: {}", stats.submitted()); + logger.info(" Completed: {}", stats.completed()); + logger.info(" Failed: {}", stats.failed()); + if (stats.pending() > 0) { + logger.info(" Pending: {}", stats.pending()); + } + }); + } + + logger.info(""); + logger.info("Total downloads completed: {}", totalCompleted); + logger.info("Total downloads failed: {}", totalFailed); + if (!statsOnly) { + logger.info("Items marked last_verified: {}", verifyProcessor.getVerifiedCount()); + logger.info("Items marked missing_since: {}", verifyProcessor.getMarkedMissingCount()); + } + } + + return totalCompleted > 0 ? 0 : 1; + } + + static List prioritizeUnlistedEa(List metadataList, boolean randomize) { + List ordered = new ArrayList<>(metadataList.size()); + ordered.addAll(metadataList); + Comparator priorityComparator = Comparator.comparing((metadata) -> !hasUnlistedSince(metadata)); + priorityComparator = + priorityComparator.thenComparing(metadata -> "ea".equalsIgnoreCase(metadata.getReleaseType()) ? 0 : 1); + ordered.sort(priorityComparator); + + if (randomize) { + int groupStart = 0; + while (groupStart < ordered.size()) { + int groupEnd = groupStart + 1; + while (groupEnd < ordered.size() + && priorityComparator.compare(ordered.get(groupStart), ordered.get(groupEnd)) == 0) { + groupEnd++; + } + Collections.shuffle(ordered.subList(groupStart, groupEnd)); + groupStart = groupEnd; + } + } + return ordered; + } + + private static boolean hasUnlistedSince(JdkMetadata metadata) { + String unlistedSince = metadata.getUnlistedSince(); + return unlistedSince != null && !unlistedSince.isBlank(); + } + + public static class VerifyProcessor implements DefaultDownloadManager.DownloadProcessor { + private final HttpUtils httpUtils; + private final Path metadataDir; + private final boolean markMissing; + private final AtomicInteger verifiedCount = new AtomicInteger(0); + private final AtomicInteger markedMissingCount = new AtomicInteger(0); + + public VerifyProcessor(HttpUtils httpUtils, Path metadataDir, boolean markMissing) { + this.httpUtils = httpUtils; + this.metadataDir = metadataDir; + this.markMissing = markMissing; + } + + public int getVerifiedCount() { + return verifiedCount.get(); + } + + public int getMarkedMissingCount() { + return markedMissingCount.get(); + } + + /** Process a single download task */ + public void processDownload(DownloadTask task) throws IOException, InterruptedException { + JdkMetadata metadata = task.metadata(); + String filename = metadata.getFilename(); + String url = metadata.getUrl(); + String today = java.time.LocalDate.now().toString(); + + if (filename == null || url == null) { + return; + } + + if (!metadata.isValid()) { + task.downloadLogger().warn("Skipping invalid metadata for: {}", filename); + return; + } + + if (metadata.getUnlistedSince() != null) { + task.downloadLogger() + .info( + "Metadata item {} has unlisted_since={} - attempting download", + filename, + metadata.getUnlistedSince()); + } + task.downloadLogger().info("Downloading " + filename); + int status = httpUtils.urlStatus(url); + if (status >= 200 && status < 300) { + if (markMissing && metadata.getMissingSince() != null) { + task.downloadLogger().info("Clearing missing_since for {}", filename); + metadata.setMissingSince(null); + } + metadata.setLastVerified(today); + saveMetadata(task, metadataDir, metadata); + verifiedCount.incrementAndGet(); + // Report success + task.downloadLogger().info("Verification successful for {}", filename); + } else if (status == 403 || status == 404) { + if (metadata.getUnlistedSince() != null) { + task.downloadLogger() + .warn( + "Download returned 40X for unlisted package {} (unlisted_since={}). " + + "This package is most likely not available anymore and is a candidate for pruning.", + filename, + metadata.getUnlistedSince()); + } + if (markMissing && metadata.getMissingSince() == null) { + metadata.setMissingSince(today); + task.downloadLogger().warn("Marking {} as missing_since={}", filename, today); + saveMetadata(task, metadataDir, metadata); + markedMissingCount.incrementAndGet(); + } + } else { + throw new IOException("Download failed with HTTP status: " + status); + } + } + + private static Path saveMetadata(DownloadTask task, Path metadataDir, JdkMetadata metadata) throws IOException { + Path distroMetadataDir = metadataDir.resolve(task.distro()); + Files.createDirectories(distroMetadataDir); + Path metadataFile = distroMetadataDir.resolve(metadata.metadataFile()); + MetadataUtils.saveMetadataFile(metadataFile, metadata); + return metadataFile; + } + } +} diff --git a/src/main/java/dev/jbang/jdkdb/model/JdkMetadata.java b/src/main/java/dev/jbang/jdkdb/model/JdkMetadata.java index fa5a156..6bba424 100644 --- a/src/main/java/dev/jbang/jdkdb/model/JdkMetadata.java +++ b/src/main/java/dev/jbang/jdkdb/model/JdkMetadata.java @@ -7,9 +7,11 @@ import dev.jbang.jdkdb.util.MetadataUtils; import java.nio.file.Path; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -146,6 +148,10 @@ public enum DistroChecksumType { @JsonInclude(JsonInclude.Include.NON_NULL) private String missingSince; + @JsonProperty("last_verified") + @JsonInclude(JsonInclude.Include.NON_NULL) + private String lastVerified; + @JsonIgnore private transient Path metadataFile; @@ -378,6 +384,15 @@ public JdkMetadata setMissingSince(String missingSince) { return this; } + public String getLastVerified() { + return lastVerified; + } + + public JdkMetadata setLastVerified(String lastVerified) { + this.lastVerified = lastVerified; + return this; + } + public Path metadataFile() { if (metadataFile == null && filename != null) { return Path.of(filename + ".json"); @@ -467,6 +482,37 @@ JdkMetadata.FileType.class, getFileType().replace(".", "_"))) { return true; } + /** + * Process the include and exclude file type options to create a filter set. + * + * @param includeFileTypes List of file types to include (null or empty means include all) + * @param excludeFileTypes List of file types to exclude (null or empty means exclude none) + * @return A set of file types to accept, or null if no filtering should be applied + */ + public static Set processFileTypeFilter( + List includeFileTypes, List excludeFileTypes) { + if ((includeFileTypes == null || includeFileTypes.isEmpty()) + && (excludeFileTypes == null || excludeFileTypes.isEmpty())) { + return null; // No filtering + } + + Set result; + if (includeFileTypes != null && !includeFileTypes.isEmpty()) { + // Start with only the included types + result = EnumSet.copyOf(includeFileTypes); + } else { + // Start with all types + result = EnumSet.allOf(FileType.class); + } + + // Remove excluded types + if (excludeFileTypes != null && !excludeFileTypes.isEmpty()) { + result.removeAll(excludeFileTypes); + } + + return result.isEmpty() ? null : result; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/dev/jbang/jdkdb/scraper/DefaultDownloadManager.java b/src/main/java/dev/jbang/jdkdb/scraper/DefaultDownloadManager.java index 7874be3..60cd141 100644 --- a/src/main/java/dev/jbang/jdkdb/scraper/DefaultDownloadManager.java +++ b/src/main/java/dev/jbang/jdkdb/scraper/DefaultDownloadManager.java @@ -41,8 +41,6 @@ public static interface DownloadProcessor { * Create a new DefaultDownloadManager. * * @param threadCount Number of parallel download threads - * @param metadataDir The directory to save metadata files - * @param checksumDir The directory to save checksum files * @param maxDownloadsPerHost Maximum number of concurrent downloads per host (default: 3) * @param limitTotal Maximum number of total downloads to accept (-1 for unlimited) * @param fileTypeFilter Set of file types to accept (null to accept all) diff --git a/src/main/java/dev/jbang/jdkdb/util/HttpUtils.java b/src/main/java/dev/jbang/jdkdb/util/HttpUtils.java index 362f173..60a350f 100644 --- a/src/main/java/dev/jbang/jdkdb/util/HttpUtils.java +++ b/src/main/java/dev/jbang/jdkdb/util/HttpUtils.java @@ -86,16 +86,15 @@ public String downloadString(String url) throws IOException, InterruptedExceptio } /** Check if a URL exists (returns 2xx status code) */ - public boolean urlExists(String url) { + public int urlStatus(String url) { try { HttpRequest request = request(url) .method("HEAD", HttpRequest.BodyPublishers.noBody()) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - int statusCode = response.statusCode(); - return statusCode >= 200 && statusCode < 300; + return response.statusCode(); } catch (IOException | InterruptedException e) { - return false; + return -1; } } diff --git a/src/test/java/dev/jbang/jdkdb/VerifyCommandTest.java b/src/test/java/dev/jbang/jdkdb/VerifyCommandTest.java new file mode 100644 index 0000000..8dbcced --- /dev/null +++ b/src/test/java/dev/jbang/jdkdb/VerifyCommandTest.java @@ -0,0 +1,53 @@ +package dev.jbang.jdkdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.jbang.jdkdb.model.JdkMetadata; +import java.util.List; +import org.junit.jupiter.api.Test; + +class VerifyCommandTest { + + @Test + void prioritizesUnlistedPresenceThenEaBeforeGa() { + JdkMetadata listedEa = metadata("listed-ea", null, "ea"); + JdkMetadata blankListedGa = metadata("blank-listed-ga", "", "ga"); + JdkMetadata newerEa = metadata("newer-ea", "2025-01-01", "ea"); + JdkMetadata oldGa = metadata("old-ga", "2024-01-01", "ga"); + JdkMetadata listedGa = metadata("listed-ga", null, "ga"); + JdkMetadata oldEa = metadata("old-ea", "2024-01-01", "ea"); + + List ordered = VerifyCommand.prioritizeUnlistedEa( + List.of(listedEa, blankListedGa, newerEa, oldGa, listedGa, oldEa), false); + + assertThat(ordered) + .extracting(JdkMetadata::getFilename) + .containsExactly("newer-ea", "old-ea", "old-ga", "listed-ea", "blank-listed-ga", "listed-ga"); + } + + @Test + void randomizationPreservesPriorityGroups() { + List ordered = VerifyCommand.prioritizeUnlistedEa( + List.of( + metadata("listed-ga", null, "ga"), + metadata("newer-ea", "2025-01-01", "ea"), + metadata("old-ga", "2024-01-01", "ga"), + metadata("old-ea", "2024-01-01", "ea"), + metadata("listed-ea", null, "ea")), + true); + + assertThat(ordered.subList(0, 2)) + .extracting(JdkMetadata::getFilename) + .containsExactlyInAnyOrder("old-ea", "newer-ea"); + assertThat(ordered.subList(2, ordered.size())) + .extracting(JdkMetadata::getFilename) + .containsExactly("old-ga", "listed-ea", "listed-ga"); + } + + private static JdkMetadata metadata(String filename, String unlistedSince, String releaseType) { + return new JdkMetadata() + .setFilename(filename) + .setUnlistedSince(unlistedSince) + .setReleaseType(releaseType); + } +}